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:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

@@ -0,0 +1,48 @@
namespace ZB.MOM.WW.LmxProxy.Client;
/// <summary>
/// TLS configuration for LmxProxy client connections
/// </summary>
public class ClientTlsConfiguration
{
/// <summary>
/// Gets or sets whether to use TLS for the connection
/// </summary>
public bool UseTls { get; set; } = false;
/// <summary>
/// Gets or sets the path to the client certificate file (optional for mutual TLS)
/// </summary>
public string? ClientCertificatePath { get; set; }
/// <summary>
/// Gets or sets the path to the client private key file (optional for mutual TLS)
/// </summary>
public string? ClientKeyPath { get; set; }
/// <summary>
/// Gets or sets the path to the CA certificate for server validation (optional)
/// </summary>
public string? ServerCaCertificatePath { get; set; }
/// <summary>
/// Gets or sets the server name override for certificate validation (optional)
/// </summary>
public string? ServerNameOverride { get; set; }
/// <summary>
/// Gets or sets whether to validate the server certificate
/// </summary>
public bool ValidateServerCertificate { get; set; } = true;
/// <summary>
/// Gets or sets whether to allow self-signed certificates (for testing only)
/// </summary>
public bool AllowSelfSignedCertificates { get; set; } = false;
/// <summary>
/// Gets or sets whether to ignore all certificate errors (DANGEROUS - for testing only)
/// WARNING: This completely disables certificate validation and should never be used in production
/// </summary>
public bool IgnoreAllCertificateErrors { get; set; } = false;
}

View File

@@ -0,0 +1,49 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Represents the connection state of an LmxProxy client.
/// </summary>
public enum ConnectionState
{
/// <summary>Not connected to the server.</summary>
Disconnected,
/// <summary>Connection attempt in progress.</summary>
Connecting,
/// <summary>Connected and ready for operations.</summary>
Connected,
/// <summary>Graceful disconnect in progress.</summary>
Disconnecting,
/// <summary>Connection failed with an error.</summary>
Error,
/// <summary>Attempting to re-establish a lost connection.</summary>
Reconnecting
}
/// <summary>
/// Event arguments for connection state change notifications.
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>The previous connection state.</summary>
public ConnectionState OldState { get; }
/// <summary>The new connection state.</summary>
public ConnectionState NewState { get; }
/// <summary>Optional message describing the state change (e.g., error details).</summary>
public string? Message { get; }
public ConnectionStateChangedEventArgs(ConnectionState oldState, ConnectionState newState, string? message = null)
{
OldState = oldState;
NewState = newState;
Message = message;
}
}

View File

@@ -0,0 +1,118 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// OPC-style quality codes for SCADA data values.
/// Based on OPC DA quality encoding as a single byte:
/// bits 76 = major (00=Bad, 01=Uncertain, 11=Good),
/// bits 52 = substatus, bits 10 = limit (00=None, 01=Low, 10=High, 11=Constant).
/// </summary>
public enum Quality : byte
{
/// <summary>Bad non-specific.</summary>
Bad = 0,
/// <summary>Bad configuration error in the server.</summary>
Bad_ConfigError = 4,
/// <summary>Bad input source is not connected.</summary>
Bad_NotConnected = 8,
/// <summary>Bad device failure detected.</summary>
Bad_DeviceFailure = 12,
/// <summary>Bad sensor failure detected.</summary>
Bad_SensorFailure = 16,
/// <summary>Bad last known value (communication lost, value stale).</summary>
Bad_LastKnownValue = 20,
/// <summary>Bad communication failure.</summary>
Bad_CommFailure = 24,
/// <summary>Bad item is out of service.</summary>
Bad_OutOfService = 28,
/// <summary>Uncertain non-specific.</summary>
Uncertain = 64,
/// <summary>Uncertain non-specific, low limited.</summary>
Uncertain_LowLimited = 65,
/// <summary>Uncertain non-specific, high limited.</summary>
Uncertain_HighLimited = 66,
/// <summary>Uncertain non-specific, constant.</summary>
Uncertain_Constant = 67,
/// <summary>Uncertain last usable value.</summary>
Uncertain_LastUsable = 68,
/// <summary>Uncertain last usable value, low limited.</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>Uncertain last usable value, high limited.</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>Uncertain last usable value, constant.</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>Uncertain sensor not accurate.</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>Uncertain sensor not accurate, low limited.</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>Uncertain sensor not accurate, high limited.</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>Uncertain sensor not accurate, constant.</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>Uncertain engineering units exceeded.</summary>
Uncertain_EuExceeded = 84,
/// <summary>Uncertain engineering units exceeded, low limited.</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>Uncertain engineering units exceeded, high limited.</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>Uncertain engineering units exceeded, constant.</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>Uncertain sub-normal operating conditions.</summary>
Uncertain_SubNormal = 88,
/// <summary>Uncertain sub-normal, low limited.</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>Uncertain sub-normal, high limited.</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>Uncertain sub-normal, constant.</summary>
Uncertain_SubNormal_C = 91,
/// <summary>Good non-specific.</summary>
Good = 192,
/// <summary>Good low limited.</summary>
Good_LowLimited = 193,
/// <summary>Good high limited.</summary>
Good_HighLimited = 194,
/// <summary>Good constant.</summary>
Good_Constant = 195,
/// <summary>Good local override active.</summary>
Good_LocalOverride = 216,
/// <summary>Good local override active, low limited.</summary>
Good_LocalOverride_LL = 217,
/// <summary>Good local override active, high limited.</summary>
Good_LocalOverride_HL = 218,
/// <summary>Good local override active, constant.</summary>
Good_LocalOverride_C = 219
}

View File

@@ -0,0 +1,8 @@
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
public static class QualityExtensions
{
public static bool IsGood(this Quality q) => (byte)q >= 128;
public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;
public static bool IsBad(this Quality q) => (byte)q < 64;
}

View File

@@ -0,0 +1,444 @@
using System.Collections.Generic;
using System.Runtime.Serialization;
using System.ServiceModel;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
// ────────────────────────────────────────────────────────────────
// Service contract
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Code-first gRPC service contract for SCADA operations.
/// </summary>
[ServiceContract(Name = "scada.ScadaService")]
public interface IScadaService
{
/// <summary>Establishes a connection with the SCADA service.</summary>
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
/// <summary>Terminates a SCADA service connection.</summary>
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
/// <summary>Retrieves the current state of a SCADA connection.</summary>
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
/// <summary>Reads a single tag value from the SCADA system.</summary>
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
/// <summary>Reads multiple tag values from the SCADA system in a batch operation.</summary>
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
/// <summary>Writes a single value to a tag in the SCADA system.</summary>
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
/// <summary>Writes multiple values to tags in the SCADA system in a batch operation.</summary>
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
/// <summary>Writes multiple values and waits for a completion flag before returning.</summary>
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
/// <summary>Subscribes to real-time value changes from specified tags.</summary>
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
/// <summary>Validates an API key for authentication.</summary>
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
}
// ────────────────────────────────────────────────────────────────
// VTQ message
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Value-Timestamp-Quality message transmitted over gRPC.
/// All values are string-encoded; timestamps are UTC ticks.
/// </summary>
[DataContract]
public class VtqMessage
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value encoded as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
/// <summary>UTC timestamp as DateTime.Ticks (100ns intervals since 0001-01-01).</summary>
[DataMember(Order = 3)]
public long TimestampUtcTicks { get; set; }
/// <summary>Quality string: "Good", "Uncertain", or "Bad".</summary>
[DataMember(Order = 4)]
public string Quality { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Connect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to establish a session with the proxy server.</summary>
[DataContract]
public class ConnectRequest
{
/// <summary>Client identifier (e.g., "ScadaLink-{guid}").</summary>
[DataMember(Order = 1)]
public string ClientId { get; set; } = string.Empty;
/// <summary>API key for authentication (empty if none required).</summary>
[DataMember(Order = 2)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from a Connect call.</summary>
[DataContract]
public class ConnectResponse
{
/// <summary>Whether the connection was established successfully.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Session ID (32-char hex GUID). Only valid when <see cref="Success"/> is <c>true</c>.</summary>
[DataMember(Order = 3)]
public string SessionId { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// Disconnect
// ────────────────────────────────────────────────────────────────
/// <summary>Request to terminate a session.</summary>
[DataContract]
public class DisconnectRequest
{
/// <summary>Active session ID to disconnect.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response from a Disconnect call.</summary>
[DataContract]
public class DisconnectResponse
{
/// <summary>Whether the disconnect succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// GetConnectionState
// ────────────────────────────────────────────────────────────────
/// <summary>Request to query connection state for a session.</summary>
[DataContract]
public class GetConnectionStateRequest
{
/// <summary>Session ID to query.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
}
/// <summary>Response with connection state information.</summary>
[DataContract]
public class GetConnectionStateResponse
{
/// <summary>Whether the session is currently connected.</summary>
[DataMember(Order = 1)]
public bool IsConnected { get; set; }
/// <summary>Client identifier for this session.</summary>
[DataMember(Order = 2)]
public string ClientId { get; set; } = string.Empty;
/// <summary>UTC ticks when the connection was established.</summary>
[DataMember(Order = 3)]
public long ConnectedSinceUtcTicks { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Read
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read a single tag.</summary>
[DataContract]
public class ReadRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to read.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Read call.</summary>
[DataContract]
public class ReadResponse
{
/// <summary>Whether the read succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message if the read failed.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>The value-timestamp-quality result.</summary>
[DataMember(Order = 3)]
public VtqMessage? Vtq { get; set; }
}
// ────────────────────────────────────────────────────────────────
// ReadBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to read multiple tags in a single round-trip.</summary>
[DataContract]
public class ReadBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to read.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
}
/// <summary>Response from a batch Read call.</summary>
[DataContract]
public class ReadBatchResponse
{
/// <summary>False if any tag read failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>VTQ results in the same order as the request tags.</summary>
[DataMember(Order = 3)]
public List<VtqMessage> Vtqs { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// Write
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write a single tag value.</summary>
[DataContract]
public class WriteRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag address to write.</summary>
[DataMember(Order = 2)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string (parsed server-side).</summary>
[DataMember(Order = 3)]
public string Value { get; set; } = string.Empty;
}
/// <summary>Response from a single-tag Write call.</summary>
[DataContract]
public class WriteResponse
{
/// <summary>Whether the write succeeded.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteItem / WriteResult
// ────────────────────────────────────────────────────────────────
/// <summary>A single tag-value pair for batch write operations.</summary>
[DataContract]
public class WriteItem
{
/// <summary>Tag address.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Value as a string.</summary>
[DataMember(Order = 2)]
public string Value { get; set; } = string.Empty;
}
/// <summary>Per-item result from a batch write operation.</summary>
[DataContract]
public class WriteResult
{
/// <summary>Tag address that was written.</summary>
[DataMember(Order = 1)]
public string Tag { get; set; } = string.Empty;
/// <summary>Whether the individual write succeeded.</summary>
[DataMember(Order = 2)]
public bool Success { get; set; }
/// <summary>Error message for this item, if any.</summary>
[DataMember(Order = 3)]
public string Message { get; set; } = string.Empty;
}
// ────────────────────────────────────────────────────────────────
// WriteBatch
// ────────────────────────────────────────────────────────────────
/// <summary>Request to write multiple tag values in a single round-trip.</summary>
[DataContract]
public class WriteBatchRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
}
/// <summary>Response from a batch Write call.</summary>
[DataContract]
public class WriteBatchResponse
{
/// <summary>Overall success — false if any item failed.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> Results { get; set; } = [];
}
// ────────────────────────────────────────────────────────────────
// WriteBatchAndWait
// ────────────────────────────────────────────────────────────────
/// <summary>
/// Request to write multiple tag values then poll a flag tag
/// until it matches an expected value or the timeout expires.
/// </summary>
[DataContract]
public class WriteBatchAndWaitRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag-value pairs to write.</summary>
[DataMember(Order = 2)]
public List<WriteItem> Items { get; set; } = [];
/// <summary>Tag to poll after writes complete.</summary>
[DataMember(Order = 3)]
public string FlagTag { get; set; } = string.Empty;
/// <summary>Expected value for the flag tag (string comparison).</summary>
[DataMember(Order = 4)]
public string FlagValue { get; set; } = string.Empty;
/// <summary>Timeout in milliseconds (default 5000 if &lt;= 0).</summary>
[DataMember(Order = 5)]
public int TimeoutMs { get; set; }
/// <summary>Poll interval in milliseconds (default 100 if &lt;= 0).</summary>
[DataMember(Order = 6)]
public int PollIntervalMs { get; set; }
}
/// <summary>Response from a WriteBatchAndWait call.</summary>
[DataContract]
public class WriteBatchAndWaitResponse
{
/// <summary>Overall operation success.</summary>
[DataMember(Order = 1)]
public bool Success { get; set; }
/// <summary>Status or error message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
/// <summary>Per-item write results.</summary>
[DataMember(Order = 3)]
public List<WriteResult> WriteResults { get; set; } = [];
/// <summary>Whether the flag tag matched the expected value before timeout.</summary>
[DataMember(Order = 4)]
public bool FlagReached { get; set; }
/// <summary>Total elapsed time in milliseconds.</summary>
[DataMember(Order = 5)]
public int ElapsedMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// Subscribe
// ────────────────────────────────────────────────────────────────
/// <summary>Request to subscribe to value change notifications on one or more tags.</summary>
[DataContract]
public class SubscribeRequest
{
/// <summary>Valid session ID.</summary>
[DataMember(Order = 1)]
public string SessionId { get; set; } = string.Empty;
/// <summary>Tag addresses to monitor.</summary>
[DataMember(Order = 2)]
public List<string> Tags { get; set; } = [];
/// <summary>Backend sampling interval in milliseconds.</summary>
[DataMember(Order = 3)]
public int SamplingMs { get; set; }
}
// ────────────────────────────────────────────────────────────────
// CheckApiKey
// ────────────────────────────────────────────────────────────────
/// <summary>Request to validate an API key without creating a session.</summary>
[DataContract]
public class CheckApiKeyRequest
{
/// <summary>API key to validate.</summary>
[DataMember(Order = 1)]
public string ApiKey { get; set; } = string.Empty;
}
/// <summary>Response from an API key validation check.</summary>
[DataContract]
public class CheckApiKeyResponse
{
/// <summary>Whether the API key is valid.</summary>
[DataMember(Order = 1)]
public bool IsValid { get; set; }
/// <summary>Validation message.</summary>
[DataMember(Order = 2)]
public string Message { get; set; } = string.Empty;
}

View File

@@ -0,0 +1,27 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
/// <param name="Value">The value.</param>
/// <param name="Timestamp">The timestamp when the value was read.</param>
/// <param name="Quality">The quality of the value.</param>
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
{
/// <summary>Creates a new VTQ with the specified value and quality, using the current UTC timestamp.</summary>
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>Creates a new VTQ with the specified value, timestamp, and quality.</summary>
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>Creates a Good-quality VTQ with the current UTC time.</summary>
public static Vtq Good(object? value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>Creates a Bad-quality VTQ with the current UTC time.</summary>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>Creates an Uncertain-quality VTQ with the current UTC time.</summary>
public static Vtq Uncertain(object? value) => new(value, DateTime.UtcNow, Quality.Uncertain);
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Interface for LmxProxy client operations
/// </summary>
public interface ILmxProxyClient : IDisposable, IAsyncDisposable
{
/// <summary>
/// Gets or sets the default timeout for operations
/// </summary>
TimeSpan DefaultTimeout { get; set; }
/// <summary>
/// Connects to the LmxProxy service
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
Task ConnectAsync(CancellationToken cancellationToken = default);
/// <summary>
/// Disconnects from the LmxProxy service
/// </summary>
Task DisconnectAsync();
/// <summary>
/// Checks if the client is connected to the service
/// </summary>
Task<bool> IsConnectedAsync();
/// <summary>
/// Reads a single tag value
/// </summary>
/// <param name="address">The tag address to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default);
/// <summary>
/// Reads multiple tag values in a single batch
/// </summary>
/// <param name="addresses">The tag addresses to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default);
/// <summary>
/// Writes a single tag value
/// </summary>
/// <param name="address">The tag address to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task WriteAsync(string address, object value, CancellationToken cancellationToken = default);
/// <summary>
/// Writes multiple tag values in a single batch
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default);
/// <summary>
/// Subscribes to tag updates
/// </summary>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
Task<ISubscription> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> onUpdate, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the current metrics snapshot
/// </summary>
Dictionary<string, object> GetMetrics();
}
}

View File

@@ -0,0 +1,150 @@
using System;
using System.Linq;
using Microsoft.Extensions.Configuration;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Factory interface for creating LmxProxyClient instances
/// </summary>
public interface ILmxProxyClientFactory
{
/// <summary>
/// Creates a new LmxProxyClient instance with default configuration
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient();
/// <summary>
/// Creates a new LmxProxyClient instance with custom configuration
/// </summary>
/// <param name="configurationName">Name of the configuration section to use</param>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient(string configurationName);
/// <summary>
/// Creates a new LmxProxyClient instance using a builder
/// </summary>
/// <param name="builderAction">Action to configure the builder</param>
/// <returns>A configured LmxProxyClient instance</returns>
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
}
/// <summary>
/// Default implementation of ILmxProxyClientFactory
/// </summary>
public class LmxProxyClientFactory : ILmxProxyClientFactory
{
private readonly IConfiguration _configuration;
/// <summary>
/// Initializes a new instance of the LmxProxyClientFactory
/// </summary>
/// <param name="configuration">Application configuration</param>
public LmxProxyClientFactory(IConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
}
/// <summary>
/// Creates a new LmxProxyClient instance with default configuration
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient()
{
return CreateClient("LmxProxy");
}
/// <summary>
/// Creates a new LmxProxyClient instance with custom configuration
/// </summary>
/// <param name="configurationName">Name of the configuration section to use</param>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient(string configurationName)
{
IConfigurationSection section = _configuration.GetSection(configurationName);
if (!section.GetChildren().Any() && section.Value == null)
{
throw new InvalidOperationException($"Configuration section '{configurationName}' not found");
}
var builder = new LmxProxyClientBuilder();
// Configure from appsettings
string? host = section["Host"];
if (!string.IsNullOrEmpty(host))
{
builder.WithHost(host);
}
if (int.TryParse(section["Port"], out int port))
{
builder.WithPort(port);
}
string? apiKey = section["ApiKey"];
if (!string.IsNullOrEmpty(apiKey))
{
builder.WithApiKey(apiKey);
}
if (TimeSpan.TryParse(section["Timeout"], out TimeSpan timeout))
{
builder.WithTimeout(timeout);
}
// Retry configuration
IConfigurationSection? retrySection = section.GetSection("Retry");
if (retrySection != null && (retrySection.GetChildren().Any() || retrySection.Value != null))
{
if (int.TryParse(retrySection["MaxAttempts"], out int maxAttempts) &&
TimeSpan.TryParse(retrySection["Delay"], out TimeSpan retryDelay))
{
builder.WithRetryPolicy(maxAttempts, retryDelay);
}
}
// SSL configuration
bool useSsl = section.GetValue<bool>("UseSsl");
if (useSsl)
{
string? certificatePath = section["CertificatePath"];
builder.WithSslCredentials(certificatePath);
}
// Metrics configuration
if (section.GetValue<bool>("EnableMetrics"))
{
builder.WithMetrics();
}
// Correlation ID configuration
string? correlationHeader = section["CorrelationIdHeader"];
if (!string.IsNullOrEmpty(correlationHeader))
{
builder.WithCorrelationIdHeader(correlationHeader);
}
// Logger is optional - don't set a default one
return builder.Build();
}
/// <summary>
/// Creates a new LmxProxyClient instance using a builder
/// </summary>
/// <param name="builderAction">Action to configure the builder</param>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
{
ArgumentNullException.ThrowIfNull(builderAction);
var builder = new LmxProxyClientBuilder();
builderAction(builder);
// Logger is optional - caller can set it via builderAction if needed
return builder.Build();
}
}
}

View File

@@ -0,0 +1,36 @@
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// API key information returned from CheckApiKey
/// </summary>
public class ApiKeyInfo
{
/// <summary>
/// Whether the API key is valid
/// </summary>
public bool IsValid { get; }
/// <summary>
/// The role assigned to the API key
/// </summary>
public string Role { get; }
/// <summary>
/// Description of the API key
/// </summary>
public string Description { get; }
/// <summary>
/// Initializes a new instance of the ApiKeyInfo class
/// </summary>
/// <param name="isValid">Whether the API key is valid</param>
/// <param name="role">The role assigned to the API key</param>
/// <param name="description">Description of the API key</param>
public ApiKeyInfo(bool isValid, string role, string description)
{
IsValid = isValid;
Role = role ?? string.Empty;
Description = description ?? string.Empty;
}
}
}

View File

@@ -0,0 +1,100 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Metrics collection for client operations
/// </summary>
internal class ClientMetrics
{
private readonly ConcurrentDictionary<string, long> _operationCounts = new();
private readonly ConcurrentDictionary<string, long> _errorCounts = new();
private readonly ConcurrentDictionary<string, List<long>> _latencies = new();
private readonly object _latencyLock = new();
/// <summary>
/// Increments the operation count for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
public void IncrementOperationCount(string operation)
{
_operationCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1);
}
/// <summary>
/// Increments the error count for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
public void IncrementErrorCount(string operation)
{
_errorCounts.AddOrUpdate(operation, 1, (_, oldValue) => oldValue + 1);
}
/// <summary>
/// Records latency for a specific operation.
/// </summary>
/// <param name="operation">The operation name.</param>
/// <param name="milliseconds">The latency in milliseconds.</param>
public void RecordLatency(string operation, long milliseconds)
{
lock (_latencyLock)
{
if (!_latencies.ContainsKey(operation))
{
_latencies[operation] = [];
}
_latencies[operation].Add(milliseconds);
// Keep only last 1000 entries to prevent memory growth
if (_latencies[operation].Count > 1000)
{
_latencies[operation].RemoveAt(0);
}
}
}
/// <summary>
/// Gets a snapshot of current metrics.
/// </summary>
/// <returns>A dictionary containing metric data.</returns>
public Dictionary<string, object> GetSnapshot()
{
var snapshot = new Dictionary<string, object>();
foreach (KeyValuePair<string, long> kvp in _operationCounts)
{
snapshot[$"{kvp.Key}_count"] = kvp.Value;
}
foreach (KeyValuePair<string, long> kvp in _errorCounts)
{
snapshot[$"{kvp.Key}_errors"] = kvp.Value;
}
lock (_latencyLock)
{
foreach (KeyValuePair<string, List<long>> kvp in _latencies)
{
if (kvp.Value.Any())
{
snapshot[$"{kvp.Key}_avg_latency_ms"] = kvp.Value.Average();
snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(kvp.Value, 95);
snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(kvp.Value, 99);
}
}
}
return snapshot;
}
private double GetPercentile(List<long> values, int percentile)
{
var sorted = values.OrderBy(x => x).ToList();
int index = (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1;
return sorted[Math.Max(0, index)];
}
}
}

View File

@@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
public partial class LmxProxyClient
{
private class CodeFirstSubscription : ISubscription
{
private readonly IScadaService _client;
private readonly string _sessionId;
private readonly List<string> _tags;
private readonly Action<string, Vtq> _onUpdate;
private readonly ILogger<LmxProxyClient> _logger;
private readonly Action<ISubscription>? _onDispose;
private readonly CancellationTokenSource _cts = new();
private Task? _processingTask;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the CodeFirstSubscription class.
/// </summary>
/// <param name="client">The gRPC ScadaService client.</param>
/// <param name="sessionId">The session identifier.</param>
/// <param name="tags">The list of tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="logger">Logger for diagnostic information.</param>
/// <param name="onDispose">Optional callback invoked when the subscription is disposed.</param>
public CodeFirstSubscription(
IScadaService client,
string sessionId,
List<string> tags,
Action<string, Vtq> onUpdate,
ILogger<LmxProxyClient> logger,
Action<ISubscription>? onDispose = null)
{
_client = client;
_sessionId = sessionId;
_tags = tags;
_onUpdate = onUpdate;
_logger = logger;
_onDispose = onDispose;
}
/// <summary>
/// Starts the subscription asynchronously and begins processing tag value updates.
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the subscription processing has started.</returns>
public Task StartAsync(CancellationToken cancellationToken = default)
{
_processingTask = ProcessUpdatesAsync(cancellationToken);
return Task.CompletedTask;
}
private async Task ProcessUpdatesAsync(CancellationToken cancellationToken)
{
try
{
var request = new SubscribeRequest
{
SessionId = _sessionId,
Tags = _tags,
SamplingMs = 1000
};
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _cts.Token);
await foreach (VtqMessage vtq in _client.SubscribeAsync(request, linkedCts.Token))
{
try
{
Vtq convertedVtq = ConvertToVtq(vtq.Tag, vtq);
_onUpdate(vtq.Tag, convertedVtq);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing subscription update for {Tag}", vtq.Tag);
}
}
}
catch (OperationCanceledException) when (_cts.Token.IsCancellationRequested || cancellationToken.IsCancellationRequested)
{
_logger.LogDebug("Subscription cancelled");
}
catch (Exception ex)
{
_logger.LogError(ex, "Error in subscription processing");
try { await _cts.CancelAsync(); } catch { /* ignore */ }
}
finally
{
if (!_disposed)
{
_disposed = true;
_onDispose?.Invoke(this);
}
}
}
/// <summary>
/// Asynchronously disposes the subscription and stops processing tag updates.
/// </summary>
/// <returns>A task representing the asynchronous disposal operation.</returns>
public async Task DisposeAsync()
{
if (_disposed) return;
_disposed = true;
await _cts.CancelAsync();
try
{
if (_processingTask != null)
{
await _processingTask;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing subscription");
}
finally
{
_cts.Dispose();
_onDispose?.Invoke(this);
}
}
/// <summary>
/// Synchronously disposes the subscription and stops processing tag updates.
/// </summary>
public void Dispose()
{
if (_disposed) return;
try
{
Task task = DisposeAsync();
if (!task.Wait(TimeSpan.FromSeconds(5)))
{
_logger.LogWarning("Subscription disposal timed out");
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error during synchronous disposal");
}
}
}
}
}

View File

@@ -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");
}
}
}
}

View File

@@ -0,0 +1,16 @@
using System;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Represents a subscription to tag value changes
/// </summary>
public interface ISubscription : IDisposable
{
/// <summary>
/// Disposes the subscription asynchronously
/// </summary>
Task DisposeAsync();
}
}

View File

@@ -0,0 +1,573 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Polly;
using ZB.MOM.WW.LmxProxy.Client.Domain;
using ZB.MOM.WW.LmxProxy.Client.Security;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Client for communicating with the LmxProxy gRPC service using protobuf-net.Grpc code-first
/// </summary>
public partial class LmxProxyClient : ILmxProxyClient
{
private static readonly string Http2InsecureSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
private readonly ILogger<LmxProxyClient> _logger;
private readonly string _host;
private readonly int _port;
private readonly string? _apiKey;
private GrpcChannel? _channel;
private IScadaService? _client;
private string _sessionId = string.Empty;
private readonly SemaphoreSlim _connectionLock = new(1, 1);
private readonly List<ISubscription> _activeSubscriptions = [];
private readonly Lock _subscriptionLock = new();
private bool _disposed;
private bool _isConnected;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private ClientConfiguration? _configuration;
private IAsyncPolicy? _retryPolicy;
private readonly ClientMetrics _metrics = new();
private Timer? _keepAliveTimer;
private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
private readonly ClientTlsConfiguration? _tlsConfiguration;
static LmxProxyClient()
{
AppContext.SetSwitch(Http2InsecureSwitch, true);
}
/// <summary>
/// Gets or sets the default timeout for operations
/// </summary>
public TimeSpan DefaultTimeout
{
get => _defaultTimeout;
set
{
if (value <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(value), "Timeout must be positive");
if (value > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(value), "Timeout cannot exceed 10 minutes");
_defaultTimeout = value;
}
}
/// <summary>
/// Initializes a new instance of the LmxProxyClient
/// </summary>
/// <param name="host">The host address of the LmxProxy service</param>
/// <param name="port">The port of the LmxProxy service</param>
/// <param name="apiKey">The API key for authentication</param>
/// <param name="logger">Optional logger instance</param>
public LmxProxyClient(string host, int port, string? apiKey = null, ILogger<LmxProxyClient>? logger = null)
: this(host, port, apiKey, null, logger)
{
}
/// <summary>
/// Creates a new instance of the LmxProxyClient with TLS configuration
/// </summary>
/// <param name="host">The host address of the LmxProxy service</param>
/// <param name="port">The port of the LmxProxy service</param>
/// <param name="apiKey">The API key for authentication</param>
/// <param name="tlsConfiguration">TLS configuration for secure connections</param>
/// <param name="logger">Optional logger instance</param>
public LmxProxyClient(string host, int port, string? apiKey, ClientTlsConfiguration? tlsConfiguration, ILogger<LmxProxyClient>? logger = null)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host cannot be null or empty", nameof(host));
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535");
_host = host;
_port = port;
_apiKey = apiKey;
_tlsConfiguration = tlsConfiguration;
_logger = logger ?? NullLogger<LmxProxyClient>.Instance;
}
/// <summary>
/// Gets whether the client is connected to the service
/// </summary>
public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId);
/// <summary>
/// Asynchronously checks if the client is connected with proper synchronization
/// </summary>
public async Task<bool> IsConnectedAsync()
{
await _connectionLock.WaitAsync();
try
{
return !_disposed && _client != null && _isConnected && !string.IsNullOrEmpty(_sessionId);
}
finally
{
_connectionLock.Release();
}
}
/// <summary>
/// Sets the builder configuration (internal use)
/// </summary>
/// <param name="configuration">The client configuration.</param>
internal void SetBuilderConfiguration(ClientConfiguration configuration)
{
_configuration = configuration;
// Setup retry policy if configured
if (configuration.MaxRetryAttempts > 0)
{
_retryPolicy = Policy
.Handle<Exception>(IsTransientError)
.WaitAndRetryAsync(
configuration.MaxRetryAttempts,
retryAttempt => configuration.RetryDelay * Math.Pow(2, retryAttempt - 1),
onRetry: (exception, timeSpan, retryCount, context) =>
{
object? correlationId = context.GetValueOrDefault("CorrelationId", "N/A");
_logger.LogWarning(exception,
"Retry {RetryCount} after {Delay}ms. CorrelationId: {CorrelationId}",
retryCount, timeSpan.TotalMilliseconds, correlationId);
});
}
}
/// <summary>
/// Reads a single tag value
/// </summary>
/// <param name="address">The tag address to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
throw new ArgumentNullException(nameof(address));
EnsureConnected();
string correlationId = GenerateCorrelationId();
var stopwatch = Stopwatch.StartNew();
try
{
_metrics.IncrementOperationCount("Read");
var request = new ReadRequest
{
SessionId = _sessionId,
Tag = address
};
ReadResponse response = await ExecuteWithRetryAsync(async () =>
await _client!.ReadAsync(request),
correlationId);
if (!response.Success)
{
_metrics.IncrementErrorCount("Read");
throw new InvalidOperationException($"Read failed for tag '{address}': {response.Message}. CorrelationId: {correlationId}");
}
_metrics.RecordLatency("Read", stopwatch.ElapsedMilliseconds);
return ConvertToVtq(address, response.Vtq);
}
catch (Exception ex)
{
_metrics.IncrementErrorCount("Read");
_logger.LogError(ex, "Read operation failed for tag: {Tag}, CorrelationId: {CorrelationId}",
address, correlationId);
throw;
}
}
/// <summary>
/// Reads multiple tag values
/// </summary>
/// <param name="addresses">The tag addresses to read.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(addresses);
var addressList = addresses.ToList();
if (!addressList.Any())
throw new ArgumentException("At least one address must be provided", nameof(addresses));
EnsureConnected();
var request = new ReadBatchRequest
{
SessionId = _sessionId,
Tags = addressList
};
ReadBatchResponse response = await _client!.ReadBatchAsync(request);
if (!response.Success)
throw new InvalidOperationException($"ReadBatch failed: {response.Message}");
var results = new Dictionary<string, Vtq>();
foreach (VtqMessage vtq in response.Vtqs)
{
results[vtq.Tag] = ConvertToVtq(vtq.Tag, vtq);
}
return results;
}
/// <summary>
/// Writes a single tag value
/// </summary>
/// <param name="address">The tag address to write.</param>
/// <param name="value">The value to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteAsync(string address, object value, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
throw new ArgumentNullException(nameof(address));
ArgumentNullException.ThrowIfNull(value);
EnsureConnected();
var request = new WriteRequest
{
SessionId = _sessionId,
Tag = address,
Value = ConvertToString(value)
};
WriteResponse response = await _client!.WriteAsync(request);
if (!response.Success)
throw new InvalidOperationException($"Write failed: {response.Message}");
}
/// <summary>
/// Writes multiple tag values
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task WriteBatchAsync(IDictionary<string, object> values, CancellationToken cancellationToken = default)
{
if (values == null || !values.Any())
throw new ArgumentException("At least one value must be provided", nameof(values));
EnsureConnected();
var request = new WriteBatchRequest
{
SessionId = _sessionId,
Items = values.Select(kvp => new WriteItem
{
Tag = kvp.Key,
Value = ConvertToString(kvp.Value)
}).ToList()
};
WriteBatchResponse response = await _client!.WriteBatchAsync(request);
if (!response.Success)
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
}
/// <summary>
/// Writes values and waits for a condition to be met
/// </summary>
/// <param name="values">The tag addresses and values to write.</param>
/// <param name="flagAddress">The flag address to write.</param>
/// <param name="flagValue">The flag value to write.</param>
/// <param name="responseAddress">The response address to monitor.</param>
/// <param name="responseValue">The expected response value.</param>
/// <param name="timeoutSeconds">Timeout in seconds.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<bool> WriteBatchAndWaitAsync(
IDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
int timeoutSeconds = 30,
CancellationToken cancellationToken = default)
{
if (values == null || !values.Any())
throw new ArgumentException("At least one value must be provided", nameof(values));
EnsureConnected();
var request = new WriteBatchAndWaitRequest
{
SessionId = _sessionId,
Items = values.Select(kvp => new WriteItem
{
Tag = kvp.Key,
Value = ConvertToString(kvp.Value)
}).ToList(),
FlagTag = flagAddress,
FlagValue = ConvertToString(flagValue),
TimeoutMs = timeoutSeconds * 1000,
PollIntervalMs = 100
};
WriteBatchAndWaitResponse response = await _client!.WriteBatchAndWaitAsync(request);
if (!response.Success)
throw new InvalidOperationException($"WriteBatchAndWait failed: {response.Message}");
return response.FlagReached;
}
/// <summary>
/// Checks the validity and permissions of the current API key
/// </summary>
/// <param name="cancellationToken">Cancellation token.</param>
public async Task<ApiKeyInfo> CheckApiKeyAsync(CancellationToken cancellationToken = default)
{
EnsureConnected();
var request = new CheckApiKeyRequest { ApiKey = _apiKey ?? string.Empty };
CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request);
return new ApiKeyInfo(
response.IsValid,
"ReadWrite", // Code-first contract doesn't return role
response.Message);
}
/// <summary>
/// Subscribes to tag value changes
/// </summary>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="onUpdate">Callback invoked when tag values change.</param>
/// <param name="cancellationToken">Cancellation token.</param>
public Task<ISubscription> SubscribeAsync(
IEnumerable<string> addresses,
Action<string, Vtq> onUpdate,
CancellationToken cancellationToken = default)
{
List<string> addressList = addresses?.ToList() ?? throw new ArgumentNullException(nameof(addresses));
if (!addressList.Any())
throw new ArgumentException("At least one address must be provided", nameof(addresses));
ArgumentNullException.ThrowIfNull(onUpdate);
EnsureConnected();
var subscription = new CodeFirstSubscription(_client!, _sessionId, addressList, onUpdate, _logger, RemoveSubscription);
// Track the subscription
lock (_subscriptionLock)
{
_activeSubscriptions.Add(subscription);
}
// Start processing updates
Task startTask = subscription.StartAsync(cancellationToken);
// Log any startup errors but don't throw
startTask.ContinueWith(t =>
{
if (t.IsFaulted)
{
_logger.LogError(t.Exception, "Subscription startup failed");
}
}, TaskContinuationOptions.OnlyOnFaulted);
return Task.FromResult<ISubscription>(subscription);
}
private void EnsureConnected()
{
if (_disposed)
throw new ObjectDisposedException(nameof(LmxProxyClient));
if (_client == null || !_isConnected || string.IsNullOrEmpty(_sessionId))
throw new InvalidOperationException("Client is not connected. Call ConnectAsync first.");
}
private static Vtq ConvertToVtq(string tag, VtqMessage? vtqMessage)
{
if (vtqMessage == null)
return new Vtq(null, DateTime.UtcNow, Quality.Bad);
// Parse the string value
object? value = vtqMessage.Value;
if (!string.IsNullOrEmpty(vtqMessage.Value))
{
// Try to parse as numeric types
if (double.TryParse(vtqMessage.Value, out double doubleVal))
value = doubleVal;
else if (bool.TryParse(vtqMessage.Value, out bool boolVal))
value = boolVal;
else
value = vtqMessage.Value;
}
var timestamp = new DateTime(vtqMessage.TimestampUtcTicks, DateTimeKind.Utc);
Quality quality = vtqMessage.Quality?.ToUpperInvariant() switch
{
"GOOD" => Quality.Good,
"UNCERTAIN" => Quality.Uncertain,
_ => Quality.Bad
};
return new Vtq(value, timestamp, quality);
}
private static string ConvertToString(object value)
{
if (value == null)
return string.Empty;
return value switch
{
DateTime dt => dt.ToUniversalTime().ToString("O"),
DateTimeOffset dto => dto.ToString("O"),
bool b => b.ToString().ToLowerInvariant(),
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// Removes a subscription from the active tracking list
/// </summary>
private void RemoveSubscription(ISubscription subscription)
{
lock (_subscriptionLock)
{
_activeSubscriptions.Remove(subscription);
}
}
/// <summary>
/// Disposes of the client and closes the connection
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
DisposeAsync().AsTask().GetAwaiter().GetResult();
GC.SuppressFinalize(this);
}
/// <summary>
/// Asynchronously disposes of the client and closes the connection
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
_disposed = true;
await DisposeCoreAsync().ConfigureAwait(false);
_connectionLock.Dispose();
GC.SuppressFinalize(this);
}
/// <summary>
/// Protected disposal implementation
/// </summary>
/// <param name="disposing">True if disposing managed resources.</param>
protected virtual void Dispose(bool disposing)
{
if (!disposing || _disposed)
return;
_disposed = true;
DisposeCoreAsync().GetAwaiter().GetResult();
_connectionLock.Dispose();
}
private async Task DisposeCoreAsync()
{
StopKeepAlive();
List<ISubscription> subscriptionsToDispose;
lock (_subscriptionLock)
{
subscriptionsToDispose = new List<ISubscription>(_activeSubscriptions);
_activeSubscriptions.Clear();
}
foreach (ISubscription subscription in subscriptionsToDispose)
{
try
{
await subscription.DisposeAsync().ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error disposing subscription");
}
}
// Disconnect session
if (_client != null && !string.IsNullOrEmpty(_sessionId))
{
try
{
var request = new DisconnectRequest { SessionId = _sessionId };
await _client.DisconnectAsync(request);
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error during disconnect on dispose");
}
}
await _connectionLock.WaitAsync().ConfigureAwait(false);
try
{
_client = null;
_sessionId = string.Empty;
_isConnected = false;
_channel?.Dispose();
_channel = null;
}
finally
{
_connectionLock.Release();
}
}
private string GenerateCorrelationId()
{
return Guid.NewGuid().ToString("N");
}
private bool IsTransientError(Exception ex)
{
// Check for transient gRPC errors
return ex.Message.Contains("Unavailable") ||
ex.Message.Contains("DeadlineExceeded") ||
ex.Message.Contains("ResourceExhausted") ||
ex.Message.Contains("Aborted");
}
private async Task<T> ExecuteWithRetryAsync<T>(Func<Task<T>> operation, string correlationId)
{
if (_retryPolicy != null)
{
var context = new Context { ["CorrelationId"] = correlationId };
return await _retryPolicy.ExecuteAsync(async (_) => await operation(), context);
}
return await operation();
}
/// <summary>
/// Gets the current metrics snapshot
/// </summary>
public Dictionary<string, object> GetMetrics() => _metrics.GetSnapshot();
}
}

View File

@@ -0,0 +1,241 @@
using System;
using System.IO;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Builder for creating configured instances of LmxProxyClient
/// </summary>
public class LmxProxyClientBuilder
{
private string? _host;
private int _port = 5050;
private string? _apiKey;
private ILogger<LmxProxyClient>? _logger;
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
private int _maxRetryAttempts = 3;
private TimeSpan _retryDelay = TimeSpan.FromSeconds(1);
private bool _enableMetrics;
private string? _correlationIdHeader;
private ClientTlsConfiguration? _tlsConfiguration;
/// <summary>
/// Sets the host address for the LmxProxy service
/// </summary>
/// <param name="host">The host address</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithHost(string host)
{
if (string.IsNullOrWhiteSpace(host))
throw new ArgumentException("Host cannot be null or empty", nameof(host));
_host = host;
return this;
}
/// <summary>
/// Sets the port for the LmxProxy service
/// </summary>
/// <param name="port">The port number</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithPort(int port)
{
if (port < 1 || port > 65535)
throw new ArgumentOutOfRangeException(nameof(port), "Port must be between 1 and 65535");
_port = port;
return this;
}
/// <summary>
/// Sets the API key for authentication
/// </summary>
/// <param name="apiKey">The API key</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithApiKey(string apiKey)
{
_apiKey = apiKey;
return this;
}
/// <summary>
/// Sets the logger instance
/// </summary>
/// <param name="logger">The logger</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithLogger(ILogger<LmxProxyClient> logger)
{
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
return this;
}
/// <summary>
/// Sets the default timeout for operations
/// </summary>
/// <param name="timeout">The timeout duration</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithTimeout(TimeSpan timeout)
{
if (timeout <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be positive");
if (timeout > TimeSpan.FromMinutes(10))
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout cannot exceed 10 minutes");
_defaultTimeout = timeout;
return this;
}
/// <summary>
/// Enables SSL/TLS with the specified certificate
/// </summary>
/// <param name="certificatePath">Path to the certificate file</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithSslCredentials(string? certificatePath = null)
{
_tlsConfiguration ??= new ClientTlsConfiguration();
_tlsConfiguration.UseTls = true;
_tlsConfiguration.ServerCaCertificatePath = string.IsNullOrWhiteSpace(certificatePath) ? null : certificatePath;
return this;
}
/// <summary>
/// Applies a full TLS configuration to the client.
/// </summary>
/// <param name="configuration">The TLS configuration to apply.</param>
/// <returns>The builder instance for method chaining.</returns>
public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration configuration)
{
_tlsConfiguration = configuration ?? throw new ArgumentNullException(nameof(configuration));
return this;
}
/// <summary>
/// Sets the retry configuration
/// </summary>
/// <param name="maxAttempts">Maximum number of retry attempts</param>
/// <param name="retryDelay">Delay between retries</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)
{
if (maxAttempts <= 0)
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max attempts must be positive");
if (retryDelay <= TimeSpan.Zero)
throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be positive");
_maxRetryAttempts = maxAttempts;
_retryDelay = retryDelay;
return this;
}
/// <summary>
/// Enables metrics collection
/// </summary>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithMetrics()
{
_enableMetrics = true;
return this;
}
/// <summary>
/// Sets the correlation ID header name for request tracing
/// </summary>
/// <param name="headerName">The header name for correlation ID</param>
/// <returns>The builder instance for method chaining</returns>
public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName)
{
if (string.IsNullOrEmpty(headerName))
throw new ArgumentException("Header name cannot be null or empty", nameof(headerName));
_correlationIdHeader = headerName;
return this;
}
/// <summary>
/// Builds the configured LmxProxyClient instance
/// </summary>
/// <returns>A configured LmxProxyClient instance</returns>
public LmxProxyClient Build()
{
if (string.IsNullOrWhiteSpace(_host))
throw new InvalidOperationException("Host must be specified");
ValidateTlsConfiguration();
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
{
DefaultTimeout = _defaultTimeout
};
// Store additional configuration for future use
client.SetBuilderConfiguration(new ClientConfiguration
{
MaxRetryAttempts = _maxRetryAttempts,
RetryDelay = _retryDelay,
EnableMetrics = _enableMetrics,
CorrelationIdHeader = _correlationIdHeader
});
return client;
}
private void ValidateTlsConfiguration()
{
if (_tlsConfiguration?.UseTls != true)
{
return;
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ServerCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ServerCaCertificatePath))
{
throw new FileNotFoundException(
$"Certificate file not found: {_tlsConfiguration.ServerCaCertificatePath}",
_tlsConfiguration.ServerCaCertificatePath);
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCertificatePath))
{
throw new FileNotFoundException(
$"Client certificate file not found: {_tlsConfiguration.ClientCertificatePath}",
_tlsConfiguration.ClientCertificatePath);
}
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientKeyPath) &&
!File.Exists(_tlsConfiguration.ClientKeyPath))
{
throw new FileNotFoundException(
$"Client key file not found: {_tlsConfiguration.ClientKeyPath}",
_tlsConfiguration.ClientKeyPath);
}
}
}
/// <summary>
/// Internal configuration class for storing builder settings
/// </summary>
internal class ClientConfiguration
{
/// <summary>
/// Gets or sets the maximum number of retry attempts.
/// </summary>
public int MaxRetryAttempts { get; set; }
/// <summary>
/// Gets or sets the retry delay.
/// </summary>
public TimeSpan RetryDelay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether metrics are enabled.
/// </summary>
public bool EnableMetrics { get; set; }
/// <summary>
/// Gets or sets the correlation ID header name.
/// </summary>
public string? CorrelationIdHeader { get; set; }
}
}

View File

@@ -0,0 +1,4 @@
using System.Runtime.CompilerServices;
// Expose internal members to test assembly
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]

View File

@@ -0,0 +1,184 @@
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Security;
using System.Security.Authentication;
using System.Security.Cryptography.X509Certificates;
using Grpc.Net.Client;
using Microsoft.Extensions.Logging;
namespace ZB.MOM.WW.LmxProxy.Client.Security;
internal static class GrpcChannelFactory
{
private const string Http2UnencryptedSwitch = "System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport";
static GrpcChannelFactory()
{
AppContext.SetSwitch(Http2UnencryptedSwitch, true);
}
/// <summary>
/// Creates a gRPC channel with optional TLS configuration.
/// </summary>
/// <param name="address">The server address.</param>
/// <param name="tlsConfiguration">Optional TLS configuration.</param>
/// <param name="logger">The logger.</param>
/// <returns>A configured gRPC channel.</returns>
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger)
{
var options = new GrpcChannelOptions
{
HttpHandler = CreateHttpHandler(tlsConfiguration, logger)
};
return GrpcChannel.ForAddress(address, options);
}
private static HttpMessageHandler CreateHttpHandler(ClientTlsConfiguration? tlsConfiguration, ILogger logger)
{
var handler = new SocketsHttpHandler
{
AutomaticDecompression = DecompressionMethods.None,
AllowAutoRedirect = false,
EnableMultipleHttp2Connections = true
};
if (tlsConfiguration?.UseTls == true)
{
ConfigureTls(handler, tlsConfiguration, logger);
}
return handler;
}
private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tlsConfiguration, ILogger logger)
{
SslClientAuthenticationOptions sslOptions = handler.SslOptions;
sslOptions.EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13;
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride))
{
sslOptions.TargetHost = tlsConfiguration.ServerNameOverride;
}
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ClientCertificatePath) &&
!string.IsNullOrWhiteSpace(tlsConfiguration.ClientKeyPath))
{
try
{
var clientCertificate = X509Certificate2.CreateFromPemFile(
tlsConfiguration.ClientCertificatePath,
tlsConfiguration.ClientKeyPath);
clientCertificate = new X509Certificate2(clientCertificate.Export(X509ContentType.Pfx));
sslOptions.ClientCertificates ??= new X509CertificateCollection();
sslOptions.ClientCertificates.Add(clientCertificate);
logger.LogInformation("Configured client certificate for mutual TLS ({CertificatePath})", tlsConfiguration.ClientCertificatePath);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load client certificate from {CertificatePath}", tlsConfiguration.ClientCertificatePath);
}
}
sslOptions.RemoteCertificateValidationCallback = (_, certificate, chain, sslPolicyErrors) =>
ValidateServerCertificate(tlsConfiguration, logger, certificate, chain, sslPolicyErrors);
}
private static bool ValidateServerCertificate(
ClientTlsConfiguration tlsConfiguration,
ILogger logger,
X509Certificate? certificate,
X509Chain? chain,
SslPolicyErrors sslPolicyErrors)
{
if (tlsConfiguration.IgnoreAllCertificateErrors)
{
logger.LogWarning("SECURITY WARNING: Ignoring all certificate validation errors for LmxProxy gRPC connection.");
return true;
}
if (certificate is null)
{
logger.LogWarning("Server certificate was null.");
return false;
}
if (!tlsConfiguration.ValidateServerCertificate)
{
logger.LogWarning("SECURITY WARNING: Server certificate validation disabled for LmxProxy gRPC connection.");
return true;
}
X509Certificate2 certificate2 = certificate as X509Certificate2 ?? new X509Certificate2(certificate);
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerNameOverride))
{
string dnsName = certificate2.GetNameInfo(X509NameType.DnsName, forIssuer: false);
if (!string.Equals(dnsName, tlsConfiguration.ServerNameOverride, StringComparison.OrdinalIgnoreCase))
{
logger.LogWarning("Server certificate subject '{Subject}' does not match expected host '{ExpectedHost}'",
dnsName, tlsConfiguration.ServerNameOverride);
return false;
}
}
using X509Chain validationChain = chain ?? new X509Chain();
validationChain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
validationChain.ChainPolicy.VerificationFlags = X509VerificationFlags.NoFlag;
if (!string.IsNullOrWhiteSpace(tlsConfiguration.ServerCaCertificatePath) &&
File.Exists(tlsConfiguration.ServerCaCertificatePath))
{
try
{
X509Certificate2 ca = LoadCertificate(tlsConfiguration.ServerCaCertificatePath);
validationChain.ChainPolicy.CustomTrustStore.Add(ca);
validationChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Failed to load CA certificate from {Path}", tlsConfiguration.ServerCaCertificatePath);
}
}
if (tlsConfiguration.AllowSelfSignedCertificates)
{
validationChain.ChainPolicy.VerificationFlags |= X509VerificationFlags.AllowUnknownCertificateAuthority;
}
bool isValid = validationChain.Build(certificate2);
if (isValid)
{
return true;
}
if (tlsConfiguration.AllowSelfSignedCertificates &&
validationChain.ChainStatus.All(status =>
status.Status == X509ChainStatusFlags.UntrustedRoot ||
status.Status == X509ChainStatusFlags.PartialChain))
{
logger.LogWarning("Accepting self-signed certificate for {Subject}", certificate2.Subject);
return true;
}
string statusMessage = string.Join(", ", validationChain.ChainStatus.Select(s => s.Status));
logger.LogWarning("Server certificate validation failed: {Status}", statusMessage);
return false;
}
private static X509Certificate2 LoadCertificate(string path)
{
try
{
return X509Certificate2.CreateFromPemFile(path);
}
catch
{
return new X509Certificate2(File.ReadAllBytes(path));
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Extension methods for registering LmxProxyClient with dependency injection
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Adds LmxProxyClient services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(this IServiceCollection services, IConfiguration configuration)
{
return services.AddLmxProxyClient(configuration, "LmxProxy");
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with a specific configuration section
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <param name="configurationSection">Name of the configuration section</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services,
IConfiguration configuration,
string configurationSection)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a singleton client with default configuration
services.AddSingleton<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configurationSection);
});
return services;
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with custom configuration
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configureClient">Action to configure the client builder</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddLmxProxyClient(
this IServiceCollection services,
Action<LmxProxyClientBuilder> configureClient)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a singleton client with custom configuration
services.AddSingleton<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configureClient);
});
return services;
}
/// <summary>
/// Adds LmxProxyClient services to the service collection with scoped lifetime
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="configuration">Application configuration</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddScopedLmxProxyClient(
this IServiceCollection services,
IConfiguration configuration)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a scoped client
services.AddScoped<LmxProxyClient>(provider =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient();
});
return services;
}
/// <summary>
/// Adds named LmxProxyClient services to the service collection
/// </summary>
/// <param name="services">The service collection</param>
/// <param name="name">Name for the client</param>
/// <param name="configureClient">Action to configure the client builder</param>
/// <returns>The service collection for chaining</returns>
public static IServiceCollection AddNamedLmxProxyClient(
this IServiceCollection services,
string name,
Action<LmxProxyClientBuilder> configureClient)
{
services.AddSingleton<ILmxProxyClientFactory, LmxProxyClientFactory>();
// Register a keyed singleton
services.AddKeyedSingleton<LmxProxyClient>(name, (provider, _) =>
{
ILmxProxyClientFactory factory = provider.GetRequiredService<ILmxProxyClientFactory>();
return factory.CreateClient(configureClient);
});
return services;
}
}
/// <summary>
/// Configuration options for LmxProxyClient
/// </summary>
public class LmxProxyClientOptions
{
/// <summary>
/// Gets or sets the host address
/// </summary>
public string Host { get; set; } = "localhost";
/// <summary>
/// Gets or sets the port number
/// </summary>
public int Port { get; set; } = 5050;
/// <summary>
/// Gets or sets the API key
/// </summary>
public string? ApiKey { get; set; }
/// <summary>
/// Gets or sets the timeout duration
/// </summary>
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
/// <summary>
/// Gets or sets whether to use SSL
/// </summary>
public bool UseSsl { get; set; }
/// <summary>
/// Gets or sets the certificate path for SSL
/// </summary>
public string? CertificatePath { get; set; }
/// <summary>
/// Gets or sets whether to enable metrics
/// </summary>
public bool EnableMetrics { get; set; }
/// <summary>
/// Gets or sets the correlation ID header name
/// </summary>
public string? CorrelationIdHeader { get; set; }
/// <summary>
/// Gets or sets the retry configuration
/// </summary>
public RetryOptions? Retry { get; set; }
}
/// <summary>
/// Retry configuration options
/// </summary>
public class RetryOptions
{
/// <summary>
/// Gets or sets the maximum number of retry attempts
/// </summary>
public int MaxAttempts { get; set; } = 3;
/// <summary>
/// Gets or sets the delay between retries
/// </summary>
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
}
}

View File

@@ -0,0 +1,260 @@
using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Client.Domain;
namespace ZB.MOM.WW.LmxProxy.Client
{
/// <summary>
/// Extension methods for streaming operations with the LmxProxy client
/// </summary>
public static class StreamingExtensions
{
/// <summary>
/// Reads multiple tag values as an async stream for efficient memory usage with large datasets
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to read</param>
/// <param name="batchSize">Size of each batch to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>An async enumerable of tag values</returns>
public static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int batchSize = 100,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive");
var batch = new List<string>(batchSize);
int errorCount = 0;
const int maxConsecutiveErrors = 3;
foreach (string address in addresses)
{
batch.Add(address);
if (batch.Count >= batchSize)
{
bool success = false;
int retries = 0;
const int maxRetries = 2;
while (!success && retries < maxRetries)
{
IDictionary<string, Vtq>? results = null;
Exception? lastException = null;
try
{
results = await client.ReadBatchAsync(batch, cancellationToken);
errorCount = 0; // Reset error count on success
success = true;
}
catch (OperationCanceledException)
{
throw; // Don't retry on cancellation
}
catch (Exception ex)
{
lastException = ex;
retries++;
errorCount++;
if (errorCount >= maxConsecutiveErrors)
{
throw new InvalidOperationException(
$"Stream reading failed after {maxConsecutiveErrors} consecutive errors", ex);
}
if (retries >= maxRetries)
{
// Log error and continue with next batch
System.Diagnostics.Debug.WriteLine($"Failed to read batch after {maxRetries} retries: {ex.Message}");
batch.Clear();
break;
}
// Wait before retry with exponential backoff
await Task.Delay(TimeSpan.FromMilliseconds(100 * Math.Pow(2, retries - 1)), cancellationToken);
}
if (results != null)
{
foreach (KeyValuePair<string, Vtq> result in results)
{
yield return result;
}
batch.Clear();
}
}
}
cancellationToken.ThrowIfCancellationRequested();
}
// Process remaining items
if (batch.Count > 0)
{
IDictionary<string, Vtq>? results = null;
try
{
results = await client.ReadBatchAsync(batch, cancellationToken);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
// Log error for final batch but don't throw to allow partial results
System.Diagnostics.Debug.WriteLine($"Failed to read final batch: {ex.Message}");
}
if (results != null)
{
foreach (KeyValuePair<string, Vtq> result in results)
{
yield return result;
}
}
}
}
/// <summary>
/// Writes multiple tag values as an async stream for efficient memory usage with large datasets
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="values">The values to write as an async enumerable</param>
/// <param name="batchSize">Size of each batch to process</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>The number of values written</returns>
public static async Task<int> WriteStreamAsync(
this ILmxProxyClient client,
IAsyncEnumerable<KeyValuePair<string, object>> values,
int batchSize = 100,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(values);
if (batchSize <= 0)
throw new ArgumentOutOfRangeException(nameof(batchSize), "Batch size must be positive");
var batch = new Dictionary<string, object>(batchSize);
int totalWritten = 0;
await foreach (KeyValuePair<string, object> kvp in values.WithCancellation(cancellationToken))
{
batch[kvp.Key] = kvp.Value;
if (batch.Count >= batchSize)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
batch.Clear();
}
}
// Process remaining items
if (batch.Count > 0)
{
await client.WriteBatchAsync(batch, cancellationToken);
totalWritten += batch.Count;
}
return totalWritten;
}
/// <summary>
/// Processes tag values in parallel batches for maximum throughput
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to read</param>
/// <param name="processor">The async function to process each value</param>
/// <param name="maxDegreeOfParallelism">Maximum number of concurrent operations</param>
/// <param name="cancellationToken">Cancellation token</param>
public static async Task ProcessInParallelAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
Func<string, Vtq, Task> processor,
int maxDegreeOfParallelism = 4,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
ArgumentNullException.ThrowIfNull(processor);
if (maxDegreeOfParallelism <= 0)
throw new ArgumentOutOfRangeException(nameof(maxDegreeOfParallelism));
var semaphore = new SemaphoreSlim(maxDegreeOfParallelism, maxDegreeOfParallelism);
var tasks = new List<Task>();
await foreach (KeyValuePair<string, Vtq> kvp in client.ReadStreamAsync(addresses, cancellationToken: cancellationToken))
{
await semaphore.WaitAsync(cancellationToken);
var task = Task.Run(async () =>
{
try
{
await processor(kvp.Key, kvp.Value);
}
finally
{
semaphore.Release();
}
}, cancellationToken);
tasks.Add(task);
}
await Task.WhenAll(tasks);
}
/// <summary>
/// Subscribes to multiple tags and returns updates as an async stream
/// </summary>
/// <param name="client">The LmxProxy client</param>
/// <param name="addresses">The addresses to subscribe to</param>
/// <param name="pollIntervalMs">Poll interval in milliseconds</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>An async enumerable of tag updates</returns>
public static async IAsyncEnumerable<Vtq> SubscribeStreamAsync(
this ILmxProxyClient client,
IEnumerable<string> addresses,
int pollIntervalMs = 1000,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(client);
ArgumentNullException.ThrowIfNull(addresses);
var updateChannel = System.Threading.Channels.Channel.CreateUnbounded<Vtq>();
// Setup update handler
void OnUpdate(string address, Vtq vtq)
{
updateChannel.Writer.TryWrite(vtq);
}
ISubscription subscription = await client.SubscribeAsync(addresses, OnUpdate, cancellationToken);
try
{
await foreach (Vtq update in updateChannel.Reader.ReadAllAsync(cancellationToken))
{
yield return update;
}
}
finally
{
await subscription.DisposeAsync();
}
}
}
}

View File

@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Client</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Client</AssemblyName>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<IsPackable>true</IsPackable>
<Description>gRPC client library for LmxProxy service</Description>
<PlatformTarget>AnyCPU</PlatformTarget>
<Platforms>AnyCPU</Platforms>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core.Api" Version="2.71.0" />
<PackageReference Include="Grpc.Net.Client" Version="2.71.0" />
<PackageReference Include="protobuf-net.Grpc" Version="1.2.5" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.0" />
<PackageReference Include="Polly" Version="8.5.2" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,25 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<dependentAssembly>
<assemblyIdentity name="System.Threading.Tasks.Extensions" publicKeyToken="cc7b13ffcd2ddd51"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.2.0.1" newVersion="4.2.0.1"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Runtime.CompilerServices.Unsafe" publicKeyToken="b03f5f7f11d50a3a"
culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.6.0" newVersion="4.0.6.0"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Memory" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.1.2" newVersion="4.0.1.2"/>
</dependentAssembly>
<dependentAssembly>
<assemblyIdentity name="System.Buffers" publicKeyToken="cc7b13ffcd2ddd51" culture="neutral"/>
<bindingRedirect oldVersion="0.0.0.0-4.0.3.0" newVersion="4.0.3.0"/>
</dependentAssembly>
</assemblyBinding>
</runtime>
</configuration>

View File

@@ -0,0 +1,206 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Validates LmxProxy configuration settings on startup.
/// </summary>
public static class ConfigurationValidator
{
private static readonly ILogger Logger = Log.ForContext(typeof(ConfigurationValidator));
/// <summary>
/// Validates the provided configuration and returns a list of validation errors.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <returns>A list of validation error messages. Empty if configuration is valid.</returns>
public static List<string> Validate(LmxProxyConfiguration configuration)
{
var errors = new List<string>();
if (configuration == null)
{
errors.Add("Configuration is null");
return errors;
}
// Validate gRPC port
if (configuration.GrpcPort <= 0 || configuration.GrpcPort > 65535)
{
errors.Add($"Invalid gRPC port: {configuration.GrpcPort}. Must be between 1 and 65535.");
}
// Validate API key configuration file
if (string.IsNullOrWhiteSpace(configuration.ApiKeyConfigFile))
{
errors.Add("API key configuration file path is not specified.");
}
// Validate Connection settings
if (configuration.Connection != null)
{
ValidateConnectionConfiguration(configuration.Connection, errors);
}
else
{
errors.Add("Connection configuration is missing.");
}
// Validate Subscription settings
if (configuration.Subscription != null)
{
ValidateSubscriptionConfiguration(configuration.Subscription, errors);
}
// Validate Service Recovery settings
if (configuration.ServiceRecovery != null)
{
ValidateServiceRecoveryConfiguration(configuration.ServiceRecovery, errors);
}
// Validate TLS settings
if (configuration.Tls != null)
{
if (!configuration.Tls.Validate())
{
errors.Add("TLS configuration validation failed. Check the logs for details.");
}
}
return errors;
}
private static void ValidateConnectionConfiguration(ConnectionConfiguration config, List<string> errors)
{
if (config.MonitorIntervalSeconds <= 0)
{
errors.Add(
$"Invalid monitor interval: {config.MonitorIntervalSeconds} seconds. Must be greater than 0.");
}
if (config.ConnectionTimeoutSeconds <= 0)
{
errors.Add(
$"Invalid connection timeout: {config.ConnectionTimeoutSeconds} seconds. Must be greater than 0.");
}
if (config.ReadTimeoutSeconds <= 0)
{
errors.Add($"Invalid read timeout: {config.ReadTimeoutSeconds} seconds. Must be greater than 0.");
}
if (config.WriteTimeoutSeconds <= 0)
{
errors.Add($"Invalid write timeout: {config.WriteTimeoutSeconds} seconds. Must be greater than 0.");
}
if (config.MaxConcurrentOperations.HasValue && config.MaxConcurrentOperations.Value <= 0)
{
errors.Add(
$"Invalid max concurrent operations: {config.MaxConcurrentOperations}. Must be greater than 0.");
}
// Validate node and galaxy names if provided
if (!string.IsNullOrWhiteSpace(config.NodeName) && config.NodeName?.Length > 255)
{
errors.Add($"Node name is too long: {config.NodeName.Length} characters. Maximum is 255.");
}
if (!string.IsNullOrWhiteSpace(config.GalaxyName) && config.GalaxyName?.Length > 255)
{
errors.Add($"Galaxy name is too long: {config.GalaxyName.Length} characters. Maximum is 255.");
}
}
private static void ValidateSubscriptionConfiguration(SubscriptionConfiguration config, List<string> errors)
{
if (config.ChannelCapacity <= 0)
{
errors.Add($"Invalid channel capacity: {config.ChannelCapacity}. Must be greater than 0.");
}
if (config.ChannelCapacity > 100000)
{
errors.Add($"Channel capacity too large: {config.ChannelCapacity}. Maximum recommended is 100000.");
}
string[] validChannelModes = { "DropOldest", "DropNewest", "Wait" };
if (!validChannelModes.Contains(config.ChannelFullMode))
{
errors.Add(
$"Invalid channel full mode: {config.ChannelFullMode}. Valid values are: {string.Join(", ", validChannelModes)}");
}
}
private static void ValidateServiceRecoveryConfiguration(ServiceRecoveryConfiguration config,
List<string> errors)
{
if (config.FirstFailureDelayMinutes < 0)
{
errors.Add(
$"Invalid first failure delay: {config.FirstFailureDelayMinutes} minutes. Must be 0 or greater.");
}
if (config.SecondFailureDelayMinutes < 0)
{
errors.Add(
$"Invalid second failure delay: {config.SecondFailureDelayMinutes} minutes. Must be 0 or greater.");
}
if (config.SubsequentFailureDelayMinutes < 0)
{
errors.Add(
$"Invalid subsequent failure delay: {config.SubsequentFailureDelayMinutes} minutes. Must be 0 or greater.");
}
if (config.ResetPeriodDays <= 0)
{
errors.Add($"Invalid reset period: {config.ResetPeriodDays} days. Must be greater than 0.");
}
}
/// <summary>
/// Logs validation results and returns whether the configuration is valid.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <returns>True if configuration is valid; otherwise, false.</returns>
public static bool ValidateAndLog(LmxProxyConfiguration configuration)
{
List<string> errors = Validate(configuration);
if (errors.Any())
{
Logger.Error("Configuration validation failed with {ErrorCount} errors:", errors.Count);
foreach (string? error in errors)
{
Logger.Error(" - {ValidationError}", error);
}
return false;
}
Logger.Information("Configuration validation successful");
return true;
}
/// <summary>
/// Throws an exception if the configuration is invalid.
/// </summary>
/// <param name="configuration">The configuration to validate.</param>
/// <exception cref="InvalidOperationException">Thrown when configuration is invalid.</exception>
public static void ValidateOrThrow(LmxProxyConfiguration configuration)
{
List<string> errors = Validate(configuration);
if (errors.Any())
{
string message = $"Configuration validation failed with {errors.Count} error(s):\n" +
string.Join("\n", errors.Select(e => $" - {e}"));
throw new InvalidOperationException(message);
}
}
}
}

View File

@@ -0,0 +1,110 @@
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration settings for LmxProxy service
/// </summary>
public class LmxProxyConfiguration
{
/// <summary>
/// gRPC server port
/// </summary>
public int GrpcPort { get; set; } = 50051;
/// <summary>
/// Subscription management settings
/// </summary>
public SubscriptionConfiguration Subscription { get; set; } = new();
/// <summary>
/// Windows service recovery settings
/// </summary>
public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new();
/// <summary>
/// API key configuration file path
/// </summary>
public string ApiKeyConfigFile { get; set; } = "apikeys.json";
/// <summary>
/// MxAccess connection settings
/// </summary>
public ConnectionConfiguration Connection { get; set; } = new();
/// <summary>
/// TLS/SSL configuration for secure gRPC communication
/// </summary>
public TlsConfiguration Tls { get; set; } = new();
/// <summary>
/// Web server configuration for status display
/// </summary>
public WebServerConfiguration WebServer { get; set; } = new();
}
/// <summary>
/// Configuration for MxAccess connection monitoring and reconnection
/// </summary>
public class ConnectionConfiguration
{
/// <summary>
/// Interval in seconds between connection health checks
/// </summary>
public int MonitorIntervalSeconds { get; set; } = 5;
/// <summary>
/// Timeout in seconds for initial connection attempts
/// </summary>
public int ConnectionTimeoutSeconds { get; set; } = 30;
/// <summary>
/// Whether to automatically reconnect when connection is lost
/// </summary>
public bool AutoReconnect { get; set; } = true;
/// <summary>
/// Timeout in seconds for read operations
/// </summary>
public int ReadTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Timeout in seconds for write operations
/// </summary>
public int WriteTimeoutSeconds { get; set; } = 5;
/// <summary>
/// Maximum number of concurrent read/write operations allowed
/// </summary>
public int? MaxConcurrentOperations { get; set; } = 10;
/// <summary>
/// Name of the node to connect to (optional)
/// </summary>
public string? NodeName { get; set; }
/// <summary>
/// Name of the galaxy to connect to (optional)
/// </summary>
public string? GalaxyName { get; set; }
}
/// <summary>
/// Configuration for web server that displays status information
/// </summary>
public class WebServerConfiguration
{
/// <summary>
/// Whether the web server is enabled
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Port number for the web server
/// </summary>
public int Port { get; set; } = 8080;
/// <summary>
/// Prefix URL for the web server (default: http://+:{Port}/)
/// </summary>
public string? Prefix { get; set; }
}
}

View File

@@ -0,0 +1,28 @@
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration for Windows service recovery
/// </summary>
public class ServiceRecoveryConfiguration
{
/// <summary>
/// Minutes to wait before restart on first failure
/// </summary>
public int FirstFailureDelayMinutes { get; set; } = 1;
/// <summary>
/// Minutes to wait before restart on second failure
/// </summary>
public int SecondFailureDelayMinutes { get; set; } = 5;
/// <summary>
/// Minutes to wait before restart on subsequent failures
/// </summary>
public int SubsequentFailureDelayMinutes { get; set; } = 10;
/// <summary>
/// Days before resetting the failure count
/// </summary>
public int ResetPeriodDays { get; set; } = 1;
}
}

View File

@@ -0,0 +1,18 @@
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration for subscription management
/// </summary>
public class SubscriptionConfiguration
{
/// <summary>
/// Buffer size for each client's channel (number of messages)
/// </summary>
public int ChannelCapacity { get; set; } = 1000;
/// <summary>
/// Strategy when channel buffer is full: "DropOldest", "DropNewest", or "Wait"
/// </summary>
public string ChannelFullMode { get; set; } = "DropOldest";
}
}

View File

@@ -0,0 +1,90 @@
using System.IO;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
{
/// <summary>
/// Configuration for TLS/SSL settings for secure gRPC communication
/// </summary>
public class TlsConfiguration
{
/// <summary>
/// Gets or sets whether TLS is enabled for gRPC communication
/// </summary>
public bool Enabled { get; set; } = false;
/// <summary>
/// Gets or sets the path to the server certificate file (.pem or .crt)
/// </summary>
public string ServerCertificatePath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to the server private key file (.key)
/// </summary>
public string ServerKeyPath { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the path to the certificate authority file for client certificate validation (optional)
/// </summary>
public string? ClientCaCertificatePath { get; set; }
/// <summary>
/// Gets or sets whether to require client certificates for mutual TLS
/// </summary>
public bool RequireClientCertificate { get; set; } = false;
/// <summary>
/// Gets or sets whether to check certificate revocation
/// </summary>
public bool CheckCertificateRevocation { get; set; } = true;
/// <summary>
/// Validates the TLS configuration
/// </summary>
/// <returns>True if configuration is valid, false otherwise</returns>
public bool Validate()
{
if (!Enabled)
{
return true; // No validation needed if TLS is disabled
}
if (string.IsNullOrWhiteSpace(ServerCertificatePath))
{
Log.Error("TLS is enabled but ServerCertificatePath is not configured");
return false;
}
if (string.IsNullOrWhiteSpace(ServerKeyPath))
{
Log.Error("TLS is enabled but ServerKeyPath is not configured");
return false;
}
if (!File.Exists(ServerCertificatePath))
{
Log.Warning("Server certificate file not found: {Path} - will be auto-generated on startup",
ServerCertificatePath);
}
if (!File.Exists(ServerKeyPath))
{
Log.Warning("Server key file not found: {Path} - will be auto-generated on startup", ServerKeyPath);
}
if (RequireClientCertificate && string.IsNullOrWhiteSpace(ClientCaCertificatePath))
{
Log.Error("Client certificate is required but ClientCaCertificatePath is not configured");
return false;
}
if (!string.IsNullOrWhiteSpace(ClientCaCertificatePath) && !File.Exists(ClientCaCertificatePath))
{
Log.Warning("Client CA certificate file not found: {Path} - will be auto-generated on startup",
ClientCaCertificatePath);
}
return true;
}
}
}

View File

@@ -0,0 +1,23 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Per-client subscription statistics.
/// </summary>
public class ClientStats
{
/// <summary>
/// Gets or sets the number of tags the client is subscribed to.
/// </summary>
public int SubscribedTags { get; set; }
/// <summary>
/// Gets or sets the number of delivered messages.
/// </summary>
public long DeliveredMessages { get; set; }
/// <summary>
/// Gets or sets the number of dropped messages.
/// </summary>
public long DroppedMessages { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Represents the state of a SCADA client connection.
/// </summary>
public enum ConnectionState
{
/// <summary>
/// The client is disconnected.
/// </summary>
Disconnected,
/// <summary>
/// The client is in the process of connecting.
/// </summary>
Connecting,
/// <summary>
/// The client is connected.
/// </summary>
Connected,
/// <summary>
/// The client is in the process of disconnecting.
/// </summary>
Disconnecting,
/// <summary>
/// The client encountered an error.
/// </summary>
Error,
/// <summary>
/// The client is reconnecting after a connection loss.
/// </summary>
Reconnecting
}
}

View File

@@ -0,0 +1,45 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Event arguments for SCADA client connection state changes.
/// </summary>
public class ConnectionStateChangedEventArgs : EventArgs
{
/// <summary>
/// Initializes a new instance of the <see cref="ConnectionStateChangedEventArgs" /> class.
/// </summary>
/// <param name="previousState">The previous connection state.</param>
/// <param name="currentState">The current connection state.</param>
/// <param name="message">Optional message providing additional information about the state change.</param>
public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
string? message = null)
{
PreviousState = previousState;
CurrentState = currentState;
Timestamp = DateTime.UtcNow;
Message = message;
}
/// <summary>
/// Gets the previous connection state.
/// </summary>
public ConnectionState PreviousState { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
public ConnectionState CurrentState { get; }
/// <summary>
/// Gets the timestamp when the state change occurred.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets additional information about the state change, such as error messages.
/// </summary>
public string? Message { get; }
}
}

View File

@@ -0,0 +1,104 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Interface for SCADA system clients.
/// </summary>
public interface IScadaClient : IAsyncDisposable
{
/// <summary>
/// Gets the connection status.
/// </summary>
bool IsConnected { get; }
/// <summary>
/// Gets the current connection state.
/// </summary>
ConnectionState ConnectionState { get; }
/// <summary>
/// Occurs when the connection state changes.
/// </summary>
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
/// <summary>
/// Connects to the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task ConnectAsync(CancellationToken ct = default);
/// <summary>
/// Disconnects from the SCADA system.
/// </summary>
/// <param name="ct">Cancellation token.</param>
Task DisconnectAsync(CancellationToken ct = default);
/// <summary>
/// Reads a single tag value from the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>The value, timestamp, and quality.</returns>
Task<Vtq> ReadAsync(string address, CancellationToken ct = default);
/// <summary>
/// Reads multiple tag values from the SCADA system.
/// </summary>
/// <param name="addresses">The tag addresses.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Dictionary of address to VTQ values.</returns>
Task<IReadOnlyDictionary<string, Vtq>>
ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
/// <summary>
/// Writes a single tag value to the SCADA system.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="value">The value to write.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteAsync(string address, object value, CancellationToken ct = default);
/// <summary>
/// Writes multiple tag values to the SCADA system.
/// </summary>
/// <param name="values">Dictionary of address to value.</param>
/// <param name="ct">Cancellation token.</param>
Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);
/// <summary>
/// Writes a batch of tag values and a flag tag, then waits for a response tag to
/// equal the expected value.
/// </summary>
/// <param name="values">The regular tag values to write.</param>
/// <param name="flagAddress">The address of the flag tag to write.</param>
/// <param name="flagValue">The value to write to the flag tag.</param>
/// <param name="responseAddress">The address of the response tag to monitor.</param>
/// <param name="responseValue">The expected value of the response tag.</param>
/// <param name="ct">Cancellation token controlling the wait.</param>
/// <returns>
/// <c>true</c> if the response value was observed before cancellation;
/// otherwise <c>false</c>.
/// </returns>
Task<bool> WriteBatchAndWaitAsync(
IReadOnlyDictionary<string, object> values,
string flagAddress,
object flagValue,
string responseAddress,
object responseValue,
CancellationToken ct = default);
/// <summary>
/// Subscribes to value changes for specified addresses.
/// </summary>
/// <param name="addresses">The tag addresses to monitor.</param>
/// <param name="callback">Callback for value changes.</param>
/// <param name="ct">Cancellation token.</param>
/// <returns>Subscription handle for unsubscribing.</returns>
Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
CancellationToken ct = default);
}
}

View File

@@ -0,0 +1,124 @@
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// OPC quality codes mapped to domain-level values.
/// The byte value matches the low-order byte of the OPC UA StatusCode,
/// so it can be persisted or round-tripped without translation.
/// </summary>
public enum Quality : byte
{
// ─────────────── Bad family (0-31) ───────────────
/// <summary>0x00 Bad [Non-Specific]</summary>
Bad = 0,
/// <summary>0x01 Unknown quality value</summary>
Unknown = 1,
/// <summary>0x04 Bad [Configuration Error]</summary>
Bad_ConfigError = 4,
/// <summary>0x08 Bad [Not Connected]</summary>
Bad_NotConnected = 8,
/// <summary>0x0C Bad [Device Failure]</summary>
Bad_DeviceFailure = 12,
/// <summary>0x10 Bad [Sensor Failure]</summary>
Bad_SensorFailure = 16,
/// <summary>0x14 Bad [Last Known Value]</summary>
Bad_LastKnownValue = 20,
/// <summary>0x18 Bad [Communication Failure]</summary>
Bad_CommFailure = 24,
/// <summary>0x1C Bad [Out of Service]</summary>
Bad_OutOfService = 28,
// ──────────── Uncertain family (64-95) ───────────
/// <summary>0x40 Uncertain [Non-Specific]</summary>
Uncertain = 64,
/// <summary>0x41 Uncertain [Non-Specific] (Low Limited)</summary>
Uncertain_LowLimited = 65,
/// <summary>0x42 Uncertain [Non-Specific] (High Limited)</summary>
Uncertain_HighLimited = 66,
/// <summary>0x43 Uncertain [Non-Specific] (Constant)</summary>
Uncertain_Constant = 67,
/// <summary>0x44 Uncertain [Last Usable]</summary>
Uncertain_LastUsable = 68,
/// <summary>0x45 Uncertain [Last Usable] (Low Limited)</summary>
Uncertain_LastUsable_LL = 69,
/// <summary>0x46 Uncertain [Last Usable] (High Limited)</summary>
Uncertain_LastUsable_HL = 70,
/// <summary>0x47 Uncertain [Last Usable] (Constant)</summary>
Uncertain_LastUsable_Cnst = 71,
/// <summary>0x50 Uncertain [Sensor Not Accurate]</summary>
Uncertain_SensorNotAcc = 80,
/// <summary>0x51 Uncertain [Sensor Not Accurate] (Low Limited)</summary>
Uncertain_SensorNotAcc_LL = 81,
/// <summary>0x52 Uncertain [Sensor Not Accurate] (High Limited)</summary>
Uncertain_SensorNotAcc_HL = 82,
/// <summary>0x53 Uncertain [Sensor Not Accurate] (Constant)</summary>
Uncertain_SensorNotAcc_C = 83,
/// <summary>0x54 Uncertain [EU Exceeded]</summary>
Uncertain_EuExceeded = 84,
/// <summary>0x55 Uncertain [EU Exceeded] (Low Limited)</summary>
Uncertain_EuExceeded_LL = 85,
/// <summary>0x56 Uncertain [EU Exceeded] (High Limited)</summary>
Uncertain_EuExceeded_HL = 86,
/// <summary>0x57 Uncertain [EU Exceeded] (Constant)</summary>
Uncertain_EuExceeded_C = 87,
/// <summary>0x58 Uncertain [Sub-Normal]</summary>
Uncertain_SubNormal = 88,
/// <summary>0x59 Uncertain [Sub-Normal] (Low Limited)</summary>
Uncertain_SubNormal_LL = 89,
/// <summary>0x5A Uncertain [Sub-Normal] (High Limited)</summary>
Uncertain_SubNormal_HL = 90,
/// <summary>0x5B Uncertain [Sub-Normal] (Constant)</summary>
Uncertain_SubNormal_C = 91,
// ─────────────── Good family (192-219) ────────────
/// <summary>0xC0 Good [Non-Specific]</summary>
Good = 192,
/// <summary>0xC1 Good [Non-Specific] (Low Limited)</summary>
Good_LowLimited = 193,
/// <summary>0xC2 Good [Non-Specific] (High Limited)</summary>
Good_HighLimited = 194,
/// <summary>0xC3 Good [Non-Specific] (Constant)</summary>
Good_Constant = 195,
/// <summary>0xD8 Good [Local Override]</summary>
Good_LocalOverride = 216,
/// <summary>0xD9 Good [Local Override] (Low Limited)</summary>
Good_LocalOverride_LL = 217,
/// <summary>0xDA Good [Local Override] (High Limited)</summary>
Good_LocalOverride_HL = 218,
/// <summary>0xDB Good [Local Override] (Constant)</summary>
Good_LocalOverride_C = 219
}
}

View File

@@ -0,0 +1,30 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Subscription statistics for all clients and tags.
/// </summary>
public class SubscriptionStats
{
/// <summary>
/// Gets or sets the total number of clients.
/// </summary>
public int TotalClients { get; set; }
/// <summary>
/// Gets or sets the total number of tags.
/// </summary>
public int TotalTags { get; set; }
/// <summary>
/// Gets or sets the mapping of tag addresses to client counts.
/// </summary>
public Dictionary<string, int> TagClientCounts { get; set; } = new();
/// <summary>
/// Gets or sets the mapping of client IDs to their statistics.
/// </summary>
public Dictionary<string, ClientStats> ClientStats { get; set; } = new();
}
}

View File

@@ -0,0 +1,129 @@
using System;
namespace ZB.MOM.WW.LmxProxy.Host.Domain
{
/// <summary>
/// Value, Timestamp, and Quality structure for SCADA data.
/// </summary>
public readonly struct Vtq : IEquatable<Vtq>
{
/// <summary>
/// Gets the value.
/// </summary>
public object? Value { get; }
/// <summary>
/// Gets the timestamp when the value was read.
/// </summary>
public DateTime Timestamp { get; }
/// <summary>
/// Gets the quality of the value.
/// </summary>
public Quality Quality { get; }
/// <summary>
/// Initializes a new instance of the <see cref="Vtq" /> struct.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
public Vtq(object? value, DateTime timestamp, Quality quality)
{
Value = value;
Timestamp = timestamp;
Quality = quality;
}
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value and quality, using the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, Quality quality) => new(value, DateTime.UtcNow, quality);
/// <summary>
/// Creates a new <see cref="Vtq" /> instance with the specified value, timestamp, and quality.
/// </summary>
/// <param name="value">The value.</param>
/// <param name="timestamp">The timestamp when the value was read.</param>
/// <param name="quality">The quality of the value.</param>
/// <returns>A new <see cref="Vtq" /> instance.</returns>
public static Vtq New(object value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with good quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with good quality.</returns>
public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with bad quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value. Optional.</param>
/// <returns>A new <see cref="Vtq" /> instance with bad quality.</returns>
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
/// <summary>
/// Creates a <see cref="Vtq" /> instance with uncertain quality and the current UTC timestamp.
/// </summary>
/// <param name="value">The value.</param>
/// <returns>A new <see cref="Vtq" /> instance with uncertain quality.</returns>
public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);
/// <summary>
/// Determines whether the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="other">The <see cref="Vtq" /> to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified <see cref="Vtq" /> is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public bool Equals(Vtq other) =>
Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;
/// <summary>
/// Determines whether the specified object is equal to the current <see cref="Vtq" />.
/// </summary>
/// <param name="obj">The object to compare with the current <see cref="Vtq" />.</param>
/// <returns>true if the specified object is equal to the current <see cref="Vtq" />; otherwise, false.</returns>
public override bool Equals(object obj) => obj is Vtq other && Equals(other);
/// <summary>
/// Returns the hash code for this instance.
/// </summary>
/// <returns>A 32-bit signed integer hash code.</returns>
public override int GetHashCode()
{
unchecked
{
int hashCode = Value != null ? Value.GetHashCode() : 0;
hashCode = (hashCode * 397) ^ Timestamp.GetHashCode();
hashCode = (hashCode * 397) ^ (int)Quality;
return hashCode;
}
}
/// <summary>
/// Returns a string that represents the current object.
/// </summary>
/// <returns>A string that represents the current object.</returns>
public override string ToString() =>
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are equal; otherwise, false.</returns>
public static bool operator ==(Vtq left, Vtq right) => left.Equals(right);
/// <summary>
/// Determines whether two specified instances of <see cref="Vtq" /> are not equal.
/// </summary>
/// <param name="left">The first <see cref="Vtq" /> to compare.</param>
/// <param name="right">The second <see cref="Vtq" /> to compare.</param>
/// <returns>true if left and right are not equal; otherwise, false.</returns>
public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
}
}

View File

@@ -0,0 +1,166 @@
syntax = "proto3";
option csharp_namespace = "ZB.MOM.WW.LmxProxy.Host.Grpc";
package scada;
// The SCADA service definition
service ScadaService {
// Connection management
rpc Connect(ConnectRequest) returns (ConnectResponse);
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
// Read operations
rpc Read(ReadRequest) returns (ReadResponse);
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
// Write operations
rpc Write(WriteRequest) returns (WriteResponse);
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
// Subscription operations (server streaming) - now streams VtqMessage directly
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
// Authentication
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
}
// === CONNECTION MESSAGES ===
message ConnectRequest {
string client_id = 1;
string api_key = 2;
}
message ConnectResponse {
bool success = 1;
string message = 2;
string session_id = 3;
}
message DisconnectRequest {
string session_id = 1;
}
message DisconnectResponse {
bool success = 1;
string message = 2;
}
message GetConnectionStateRequest {
string session_id = 1;
}
message GetConnectionStateResponse {
bool is_connected = 1;
string client_id = 2;
int64 connected_since_utc_ticks = 3;
}
// === VTQ MESSAGE ===
message VtqMessage {
string tag = 1;
string value = 2;
int64 timestamp_utc_ticks = 3;
string quality = 4; // "Good", "Uncertain", "Bad"
}
// === READ MESSAGES ===
message ReadRequest {
string session_id = 1;
string tag = 2;
}
message ReadResponse {
bool success = 1;
string message = 2;
VtqMessage vtq = 3;
}
message ReadBatchRequest {
string session_id = 1;
repeated string tags = 2;
}
message ReadBatchResponse {
bool success = 1;
string message = 2;
repeated VtqMessage vtqs = 3;
}
// === WRITE MESSAGES ===
message WriteRequest {
string session_id = 1;
string tag = 2;
string value = 3;
}
message WriteResponse {
bool success = 1;
string message = 2;
}
message WriteItem {
string tag = 1;
string value = 2;
}
message WriteResult {
string tag = 1;
bool success = 2;
string message = 3;
}
message WriteBatchRequest {
string session_id = 1;
repeated WriteItem items = 2;
}
message WriteBatchResponse {
bool success = 1;
string message = 2;
repeated WriteResult results = 3;
}
message WriteBatchAndWaitRequest {
string session_id = 1;
repeated WriteItem items = 2;
string flag_tag = 3;
string flag_value = 4;
int32 timeout_ms = 5;
int32 poll_interval_ms = 6;
}
message WriteBatchAndWaitResponse {
bool success = 1;
string message = 2;
repeated WriteResult write_results = 3;
bool flag_reached = 4;
int32 elapsed_ms = 5;
}
// === SUBSCRIPTION MESSAGES ===
message SubscribeRequest {
string session_id = 1;
repeated string tags = 2;
int32 sampling_ms = 3;
}
// Note: Subscribe RPC now streams VtqMessage directly (defined above)
// === AUTHENTICATION MESSAGES ===
message CheckApiKeyRequest {
string api_key = 1;
}
message CheckApiKeyResponse {
bool is_valid = 1;
string message = 2;
}

View File

@@ -0,0 +1,804 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Grpc.Core;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Security;
using ZB.MOM.WW.LmxProxy.Host.Services;
using ZB.MOM.WW.LmxProxy.Host.Grpc;
namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
{
/// <summary>
/// gRPC service implementation for SCADA operations.
/// Provides methods for connecting, reading, writing, batch operations, and subscriptions.
/// </summary>
public class ScadaGrpcService : ScadaService.ScadaServiceBase
{
private static readonly ILogger Logger = Log.ForContext<ScadaGrpcService>();
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SessionManager _sessionManager;
private readonly SubscriptionManager _subscriptionManager;
/// <summary>
/// Initializes a new instance of the <see cref="ScadaGrpcService" /> class.
/// </summary>
/// <param name="scadaClient">The SCADA client instance.</param>
/// <param name="subscriptionManager">The subscription manager instance.</param>
/// <param name="sessionManager">The session manager instance.</param>
/// <param name="performanceMetrics">Optional performance metrics service for tracking operations.</param>
/// <exception cref="ArgumentNullException">Thrown if any required argument is null.</exception>
public ScadaGrpcService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
SessionManager sessionManager,
PerformanceMetrics performanceMetrics = null)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
_performanceMetrics = performanceMetrics;
}
#region Connection Management
/// <summary>
/// Creates a new session for a client.
/// The MxAccess connection is managed separately at server startup.
/// </summary>
/// <param name="request">The connection request with client ID and API key.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="ConnectResponse" /> with session ID.</returns>
public override Task<ConnectResponse> Connect(ConnectRequest request, ServerCallContext context)
{
try
{
Logger.Information("Connect request from {Peer} - ClientId: {ClientId}",
context.Peer, request.ClientId);
// Validate that MxAccess is connected
if (!_scadaClient.IsConnected)
{
return Task.FromResult(new ConnectResponse
{
Success = false,
Message = "SCADA server is not connected to MxAccess",
SessionId = string.Empty
});
}
// Create a new session
var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey);
return Task.FromResult(new ConnectResponse
{
Success = true,
Message = "Session created successfully",
SessionId = sessionId
});
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create session for client {ClientId}", request.ClientId);
return Task.FromResult(new ConnectResponse
{
Success = false,
Message = ex.Message,
SessionId = string.Empty
});
}
}
/// <summary>
/// Terminates a client session.
/// </summary>
/// <param name="request">The disconnect request with session ID.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="DisconnectResponse" /> indicating success or failure.</returns>
public override Task<DisconnectResponse> Disconnect(DisconnectRequest request, ServerCallContext context)
{
try
{
Logger.Information("Disconnect request from {Peer} - SessionId: {SessionId}",
context.Peer, request.SessionId);
var terminated = _sessionManager.TerminateSession(request.SessionId);
return Task.FromResult(new DisconnectResponse
{
Success = terminated,
Message = terminated ? "Session terminated successfully" : "Session not found"
});
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to disconnect session {SessionId}", request.SessionId);
return Task.FromResult(new DisconnectResponse
{
Success = false,
Message = ex.Message
});
}
}
/// <summary>
/// Gets the connection state for a session.
/// </summary>
/// <param name="request">The connection state request with session ID.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="GetConnectionStateResponse" /> with connection details.</returns>
public override Task<GetConnectionStateResponse> GetConnectionState(GetConnectionStateRequest request,
ServerCallContext context)
{
var session = _sessionManager.GetSession(request.SessionId);
if (session == null)
{
return Task.FromResult(new GetConnectionStateResponse
{
IsConnected = false,
ClientId = string.Empty,
ConnectedSinceUtcTicks = 0
});
}
return Task.FromResult(new GetConnectionStateResponse
{
IsConnected = _scadaClient.IsConnected,
ClientId = session.ClientId,
ConnectedSinceUtcTicks = session.ConnectedSinceUtcTicks
});
}
#endregion
#region Read Operations
/// <summary>
/// Reads a single tag value from the SCADA system.
/// </summary>
/// <param name="request">The read request with session ID and tag.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="ReadResponse" /> with the VTQ data.</returns>
public override async Task<ReadResponse> Read(ReadRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("Read"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
return new ReadResponse
{
Success = false,
Message = "Invalid session ID",
Vtq = CreateBadVtqMessage(request.Tag)
};
}
Logger.Debug("Read request from {Peer} for {Tag}", context.Peer, request.Tag);
Vtq vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken);
scope?.SetSuccess(true);
return new ReadResponse
{
Success = true,
Message = string.Empty,
Vtq = ConvertToVtqMessage(request.Tag, vtq)
};
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to read {Tag}", request.Tag);
scope?.SetSuccess(false);
return new ReadResponse
{
Success = false,
Message = ex.Message,
Vtq = CreateBadVtqMessage(request.Tag)
};
}
}
}
/// <summary>
/// Reads multiple tag values from the SCADA system.
/// </summary>
/// <param name="request">The batch read request with session ID and tags.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="ReadBatchResponse" /> with VTQ data for each tag.</returns>
public override async Task<ReadBatchResponse> ReadBatch(ReadBatchRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("ReadBatch"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
var badResponse = new ReadBatchResponse
{
Success = false,
Message = "Invalid session ID"
};
foreach (var tag in request.Tags)
{
badResponse.Vtqs.Add(CreateBadVtqMessage(tag));
}
return badResponse;
}
Logger.Debug("ReadBatch request from {Peer} for {Count} tags", context.Peer, request.Tags.Count);
IReadOnlyDictionary<string, Vtq> results =
await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken);
var response = new ReadBatchResponse
{
Success = true,
Message = string.Empty
};
// Return results in the same order as the request tags
foreach (var tag in request.Tags)
{
if (results.TryGetValue(tag, out Vtq vtq))
{
response.Vtqs.Add(ConvertToVtqMessage(tag, vtq));
}
else
{
response.Vtqs.Add(CreateBadVtqMessage(tag));
}
}
scope?.SetSuccess(true);
return response;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to read batch");
scope?.SetSuccess(false);
var response = new ReadBatchResponse
{
Success = false,
Message = ex.Message
};
foreach (var tag in request.Tags)
{
response.Vtqs.Add(CreateBadVtqMessage(tag));
}
return response;
}
}
}
#endregion
#region Write Operations
/// <summary>
/// Writes a single tag value to the SCADA system.
/// </summary>
/// <param name="request">The write request with session ID, tag, and value.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="WriteResponse" /> indicating success or failure.</returns>
public override async Task<WriteResponse> Write(WriteRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("Write"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
return new WriteResponse
{
Success = false,
Message = "Invalid session ID"
};
}
Logger.Debug("Write request from {Peer} for {Tag}", context.Peer, request.Tag);
// Parse the string value to an appropriate type
var value = ParseValue(request.Value);
await _scadaClient.WriteAsync(request.Tag, value, context.CancellationToken);
scope?.SetSuccess(true);
return new WriteResponse
{
Success = true,
Message = string.Empty
};
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to write to {Tag}", request.Tag);
scope?.SetSuccess(false);
return new WriteResponse
{
Success = false,
Message = ex.Message
};
}
}
}
/// <summary>
/// Writes multiple tag values to the SCADA system.
/// </summary>
/// <param name="request">The batch write request with session ID and items.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="WriteBatchResponse" /> with results for each tag.</returns>
public override async Task<WriteBatchResponse> WriteBatch(WriteBatchRequest request, ServerCallContext context)
{
using (PerformanceMetrics.ITimingScope scope = _performanceMetrics?.BeginOperation("WriteBatch"))
{
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
var badResponse = new WriteBatchResponse
{
Success = false,
Message = "Invalid session ID"
};
foreach (var item in request.Items)
{
badResponse.Results.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = "Invalid session ID"
});
}
return badResponse;
}
Logger.Debug("WriteBatch request from {Peer} for {Count} items", context.Peer, request.Items.Count);
var values = new Dictionary<string, object>();
foreach (var item in request.Items)
{
values[item.Tag] = ParseValue(item.Value);
}
await _scadaClient.WriteBatchAsync(values, context.CancellationToken);
scope?.SetSuccess(true);
var response = new WriteBatchResponse
{
Success = true,
Message = string.Empty
};
foreach (var item in request.Items)
{
response.Results.Add(new WriteResult
{
Tag = item.Tag,
Success = true,
Message = string.Empty
});
}
return response;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to write batch");
scope?.SetSuccess(false);
var response = new WriteBatchResponse
{
Success = false,
Message = ex.Message
};
foreach (var item in request.Items)
{
response.Results.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = ex.Message
});
}
return response;
}
}
}
/// <summary>
/// Writes a batch of tag values and waits for a flag tag to reach a specific value.
/// </summary>
/// <param name="request">The batch write and wait request.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="WriteBatchAndWaitResponse" /> with results and flag status.</returns>
public override async Task<WriteBatchAndWaitResponse> WriteBatchAndWait(WriteBatchAndWaitRequest request,
ServerCallContext context)
{
var startTime = DateTime.UtcNow;
try
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
var badResponse = new WriteBatchAndWaitResponse
{
Success = false,
Message = "Invalid session ID",
FlagReached = false,
ElapsedMs = 0
};
foreach (var item in request.Items)
{
badResponse.WriteResults.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = "Invalid session ID"
});
}
return badResponse;
}
Logger.Debug("WriteBatchAndWait request from {Peer}", context.Peer);
var values = new Dictionary<string, object>();
foreach (var item in request.Items)
{
values[item.Tag] = ParseValue(item.Value);
}
var flagValue = ParseValue(request.FlagValue);
var pollInterval = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
using var cts = CancellationTokenSource.CreateLinkedTokenSource(context.CancellationToken);
cts.CancelAfter(TimeSpan.FromMilliseconds(request.TimeoutMs));
// Write the batch first
await _scadaClient.WriteBatchAsync(values, cts.Token);
// Poll for the flag value
var flagReached = false;
while (!cts.Token.IsCancellationRequested)
{
try
{
var flagVtq = await _scadaClient.ReadAsync(request.FlagTag, cts.Token);
if (flagVtq.Value != null && AreValuesEqual(flagVtq.Value, flagValue))
{
flagReached = true;
break;
}
await Task.Delay(pollInterval, cts.Token);
}
catch (OperationCanceledException)
{
break;
}
}
var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
var response = new WriteBatchAndWaitResponse
{
Success = true,
Message = string.Empty,
FlagReached = flagReached,
ElapsedMs = elapsedMs
};
foreach (var item in request.Items)
{
response.WriteResults.Add(new WriteResult
{
Tag = item.Tag,
Success = true,
Message = string.Empty
});
}
return response;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to write batch and wait");
var elapsedMs = (int)(DateTime.UtcNow - startTime).TotalMilliseconds;
var response = new WriteBatchAndWaitResponse
{
Success = false,
Message = ex.Message,
FlagReached = false,
ElapsedMs = elapsedMs
};
foreach (var item in request.Items)
{
response.WriteResults.Add(new WriteResult
{
Tag = item.Tag,
Success = false,
Message = ex.Message
});
}
return response;
}
}
#endregion
#region Subscription Operations
/// <summary>
/// Subscribes to value changes for specified tags and streams updates to the client.
/// </summary>
/// <param name="request">The subscribe request with session ID and tags.</param>
/// <param name="responseStream">The server stream writer for VTQ updates.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A task representing the asynchronous operation.</returns>
public override async Task Subscribe(SubscribeRequest request,
IServerStreamWriter<VtqMessage> responseStream, ServerCallContext context)
{
// Validate session
if (!_sessionManager.ValidateSession(request.SessionId))
{
Logger.Warning("Subscribe failed: Invalid session ID {SessionId}", request.SessionId);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid session ID"));
}
var clientId = Guid.NewGuid().ToString();
try
{
Logger.Information("Subscribe request from {Peer} with client ID {ClientId} for {Count} tags",
context.Peer, clientId, request.Tags.Count);
Channel<(string address, Vtq vtq)> channel = await _subscriptionManager.SubscribeAsync(
clientId,
request.Tags,
context.CancellationToken);
// Stream updates to the client until cancelled
while (!context.CancellationToken.IsCancellationRequested)
{
try
{
while (await channel.Reader.WaitToReadAsync(context.CancellationToken))
{
if (channel.Reader.TryRead(out (string address, Vtq vtq) item))
{
var vtqMessage = ConvertToVtqMessage(item.address, item.vtq);
await responseStream.WriteAsync(vtqMessage);
}
}
}
catch (OperationCanceledException)
{
break;
}
}
}
catch (OperationCanceledException)
{
Logger.Information("Subscription cancelled for client {ClientId}", clientId);
}
catch (Exception ex)
{
Logger.Error(ex, "Error in subscription for client {ClientId}", clientId);
throw;
}
finally
{
_subscriptionManager.UnsubscribeClient(clientId);
}
}
#endregion
#region Authentication
/// <summary>
/// Checks the validity of an API key.
/// </summary>
/// <param name="request">The API key check request.</param>
/// <param name="context">The gRPC server call context.</param>
/// <returns>A <see cref="CheckApiKeyResponse" /> with validity and details.</returns>
public override Task<CheckApiKeyResponse> CheckApiKey(CheckApiKeyRequest request, ServerCallContext context)
{
var response = new CheckApiKeyResponse
{
IsValid = false,
Message = "API key validation failed"
};
// Check if API key was validated by interceptor
if (context.UserState.TryGetValue("ApiKey", out object apiKeyObj) && apiKeyObj is ApiKey apiKey)
{
response.IsValid = apiKey.IsValid();
response.Message = apiKey.IsValid()
? $"API key is valid (Role: {apiKey.Role})"
: "API key is disabled";
Logger.Information("API key check - Valid: {IsValid}, Role: {Role}",
response.IsValid, apiKey.Role);
}
else
{
Logger.Warning("API key check failed - no API key in context");
}
return Task.FromResult(response);
}
#endregion
#region Value Conversion Helpers
/// <summary>
/// Converts a domain <see cref="Vtq" /> to a gRPC <see cref="VtqMessage" />.
/// </summary>
private static VtqMessage ConvertToVtqMessage(string tag, Vtq vtq)
{
return new VtqMessage
{
Tag = tag,
Value = ConvertValueToString(vtq.Value),
TimestampUtcTicks = vtq.Timestamp.Ticks,
Quality = ConvertQualityToString(vtq.Quality)
};
}
/// <summary>
/// Creates a bad quality VTQ message for error cases.
/// </summary>
private static VtqMessage CreateBadVtqMessage(string tag)
{
return new VtqMessage
{
Tag = tag,
Value = string.Empty,
TimestampUtcTicks = DateTime.UtcNow.Ticks,
Quality = "Bad"
};
}
/// <summary>
/// Converts a value to its string representation.
/// </summary>
private static string ConvertValueToString(object value)
{
if (value == null)
{
return string.Empty;
}
return value switch
{
bool b => b.ToString().ToLowerInvariant(),
DateTime dt => dt.ToUniversalTime().ToString("O"),
DateTimeOffset dto => dto.ToString("O"),
float f => f.ToString(CultureInfo.InvariantCulture),
double d => d.ToString(CultureInfo.InvariantCulture),
decimal dec => dec.ToString(CultureInfo.InvariantCulture),
Array => JsonSerializer.Serialize(value, value.GetType()),
_ => value.ToString() ?? string.Empty
};
}
/// <summary>
/// Converts a domain quality value to a string.
/// </summary>
private static string ConvertQualityToString(Domain.Quality quality)
{
// Simplified quality mapping for the new API
var qualityValue = (int)quality;
if (qualityValue >= 192) // Good family
{
return "Good";
}
if (qualityValue >= 64) // Uncertain family
{
return "Uncertain";
}
return "Bad"; // Bad family
}
/// <summary>
/// Parses a string value to an appropriate .NET type.
/// </summary>
private static object ParseValue(string value)
{
if (string.IsNullOrEmpty(value))
{
return string.Empty;
}
// Try to parse as boolean
if (bool.TryParse(value, out bool boolResult))
{
return boolResult;
}
// Try to parse as integer
if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int intResult))
{
return intResult;
}
// Try to parse as long
if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out long longResult))
{
return longResult;
}
// Try to parse as double
if (double.TryParse(value, NumberStyles.Float | NumberStyles.AllowThousands, CultureInfo.InvariantCulture,
out double doubleResult))
{
return doubleResult;
}
// Try to parse as DateTime
if (DateTime.TryParse(value, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind,
out DateTime dateResult))
{
return dateResult;
}
// Return as string
return value;
}
/// <summary>
/// Compares two values for equality.
/// </summary>
private static bool AreValuesEqual(object value1, object value2)
{
if (value1 == null && value2 == null)
{
return true;
}
if (value1 == null || value2 == null)
{
return false;
}
// Convert both to strings for comparison
var str1 = ConvertValueToString(value1);
var str2 = ConvertValueToString(value2);
return string.Equals(str1, str2, StringComparison.OrdinalIgnoreCase);
}
#endregion
}
}

View File

@@ -0,0 +1,298 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Connection management for MxAccessClient.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Asynchronously connects to the MxAccess server.
/// </summary>
/// <param name="ct">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous connect operation.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the client has been disposed.</exception>
/// <exception cref="InvalidOperationException">Thrown if registration with MxAccess fails.</exception>
/// <exception cref="Exception">Thrown if any other error occurs during connection.</exception>
public async Task ConnectAsync(CancellationToken ct = default)
{
// COM operations must run on STA thread, so we use Task.Run here
await Task.Run(ConnectInternal, ct);
// Recreate stored subscriptions after successful connection
await RecreateStoredSubscriptionsAsync();
}
/// <summary>
/// Asynchronously disconnects from the MxAccess server and cleans up resources.
/// </summary>
/// <param name="ct">A cancellation token to observe while waiting for the task to complete.</param>
/// <returns>A task that represents the asynchronous disconnect operation.</returns>
public async Task DisconnectAsync(CancellationToken ct = default)
{
// COM operations must run on STA thread, so we use Task.Run here
await Task.Run(() => DisconnectInternal(), ct);
}
/// <summary>
/// Internal synchronous connection logic.
/// </summary>
private void ConnectInternal()
{
lock (_lock)
{
ValidateNotDisposed();
if (IsConnected)
{
return;
}
try
{
Logger.Information("Attempting to connect to MxAccess");
SetConnectionState(ConnectionState.Connecting);
InitializeMxAccessConnection();
RegisterWithMxAccess();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to connect to MxAccess");
Cleanup();
SetConnectionState(ConnectionState.Disconnected, ex.Message);
throw;
}
}
}
/// <summary>
/// Validates that the client has not been disposed.
/// </summary>
private void ValidateNotDisposed()
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(MxAccessClient));
}
}
/// <summary>
/// Initializes the MxAccess COM connection and event handlers.
/// </summary>
private void InitializeMxAccessConnection()
{
// Create the COM object
_lmxProxy = new LMXProxyServer();
// Wire up event handlers
_lmxProxy.OnDataChange += OnDataChange;
_lmxProxy.OnWriteComplete += OnWriteComplete;
_lmxProxy.OperationComplete += OnOperationComplete;
}
/// <summary>
/// Registers with the MxAccess server.
/// </summary>
private void RegisterWithMxAccess()
{
// Register with the server
if (_lmxProxy == null)
{
throw new InvalidOperationException("MxAccess proxy is not initialized");
}
_connectionHandle = _lmxProxy.Register("ZB.MOM.WW.LmxProxy.Host");
if (_connectionHandle > 0)
{
SetConnectionState(ConnectionState.Connected);
Logger.Information("Successfully connected to MxAccess with handle {Handle}", _connectionHandle);
}
else
{
throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned");
}
}
/// <summary>
/// Internal synchronous disconnection logic.
/// </summary>
private void DisconnectInternal()
{
lock (_lock)
{
if (!IsConnected || _lmxProxy == null)
{
return;
}
try
{
Logger.Information("Disconnecting from MxAccess");
SetConnectionState(ConnectionState.Disconnecting);
RemoveAllSubscriptions();
UnregisterFromMxAccess();
Cleanup();
SetConnectionState(ConnectionState.Disconnected);
Logger.Information("Successfully disconnected from MxAccess");
}
catch (Exception ex)
{
Logger.Error(ex, "Error during disconnect");
Cleanup();
SetConnectionState(ConnectionState.Disconnected, ex.Message);
}
}
}
/// <summary>
/// Removes all active subscriptions.
/// </summary>
private void RemoveAllSubscriptions()
{
var subscriptionsToRemove = _subscriptions.Values.ToList();
var failedRemovals = new List<string>();
foreach (SubscriptionInfo? sub in subscriptionsToRemove)
{
if (!TryRemoveSubscription(sub))
{
failedRemovals.Add(sub.Address);
}
}
if (failedRemovals.Any())
{
Logger.Warning("Failed to cleanly remove {Count} subscriptions: {Addresses}",
failedRemovals.Count, string.Join(", ", failedRemovals));
}
_subscriptions.Clear();
_subscriptionsByHandle.Clear();
// Note: We intentionally keep _storedSubscriptions to recreate them on reconnect
}
/// <summary>
/// Attempts to remove a single subscription.
/// </summary>
private bool TryRemoveSubscription(SubscriptionInfo subscription)
{
try
{
if (_lmxProxy == null)
{
return false;
}
_lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle);
_lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle);
return true;
}
catch (Exception ex)
{
Logger.Warning(ex, "Error removing subscription for {Address}", subscription.Address);
return false;
}
}
/// <summary>
/// Unregisters from the MxAccess server.
/// </summary>
private void UnregisterFromMxAccess()
{
if (_connectionHandle > 0 && _lmxProxy != null)
{
_lmxProxy.Unregister(_connectionHandle);
_connectionHandle = 0;
}
}
/// <summary>
/// Cleans up resources and releases the COM object.
/// Removes event handlers and releases the proxy COM object if present.
/// </summary>
private void Cleanup()
{
try
{
if (_lmxProxy != null)
{
// Remove event handlers
_lmxProxy.OnDataChange -= OnDataChange;
_lmxProxy.OnWriteComplete -= OnWriteComplete;
_lmxProxy.OperationComplete -= OnOperationComplete;
// Release COM object
int refCount = Marshal.ReleaseComObject(_lmxProxy);
if (refCount > 0)
{
Logger.Warning("COM object reference count after release: {RefCount}", refCount);
// Force final release
while (refCount > 0)
{
refCount = Marshal.ReleaseComObject(_lmxProxy);
}
}
_lmxProxy = null;
}
_connectionHandle = 0;
}
catch (Exception ex)
{
Logger.Warning(ex, "Error during cleanup");
}
}
/// <summary>
/// Recreates all stored subscriptions after reconnection.
/// </summary>
private async Task RecreateStoredSubscriptionsAsync()
{
List<StoredSubscription> subscriptionsToRecreate;
lock (_lock)
{
// Create a copy to avoid holding the lock during async operations
subscriptionsToRecreate = new List<StoredSubscription>(_storedSubscriptions);
}
if (subscriptionsToRecreate.Count == 0)
{
Logger.Debug("No stored subscriptions to recreate");
return;
}
Logger.Information("Recreating {Count} stored subscription groups after reconnection",
subscriptionsToRecreate.Count);
foreach (StoredSubscription? storedSub in subscriptionsToRecreate)
{
try
{
// Recreate the subscription without storing it again
await SubscribeInternalAsync(storedSub.Addresses, storedSub.Callback, false);
Logger.Information("Successfully recreated subscription group {GroupId} with {Count} addresses",
storedSub.GroupId, storedSub.Addresses.Count);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to recreate subscription group {GroupId}", storedSub.GroupId);
}
}
}
}
}

View File

@@ -0,0 +1,166 @@
using System;
using ArchestrA.MxAccess;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Event handlers for MxAccessClient to process data changes, write completions, and operation completions.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Handles data change events from the MxAccess server.
/// </summary>
/// <param name="hLMXServerHandle">Server handle.</param>
/// <param name="phItemHandle">Item handle.</param>
/// <param name="pvItemValue">Item value.</param>
/// <param name="pwItemQuality">Item quality code.</param>
/// <param name="pftItemTimeStamp">Item timestamp.</param>
/// <param name="ItemStatus">Status array.</param>
private void OnDataChange(int hLMXServerHandle, int phItemHandle, object pvItemValue,
int pwItemQuality, object pftItemTimeStamp, ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
if (!_subscriptionsByHandle.TryGetValue(phItemHandle, out SubscriptionInfo? subscription))
{
return;
}
// Convert quality from integer
Quality quality = ConvertQuality(pwItemQuality);
DateTime timestamp = ConvertTimestamp(pftItemTimeStamp);
var vtq = new Vtq(pvItemValue, timestamp, quality);
// Invoke callback
subscription.Callback?.Invoke(subscription.Address, vtq);
}
catch (Exception ex)
{
Logger.Error(ex, "Error processing data change for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// Handles write completion events from the MxAccess server.
/// </summary>
/// <param name="hLMXServerHandle">Server handle.</param>
/// <param name="phItemHandle">Item handle.</param>
/// <param name="ItemStatus">Status array.</param>
private void OnWriteComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
{
try
{
WriteOperation? writeOp;
lock (_lock)
{
if (_pendingWrites.TryGetValue(phItemHandle, out writeOp))
{
_pendingWrites.Remove(phItemHandle);
}
}
if (writeOp != null)
{
try
{
if (ItemStatus is { Length: > 0 })
{
var status = ItemStatus[0];
if (status.success == 0)
{
string errorMsg = GetWriteErrorMessage(status.detail);
Logger.Warning(
"Write failed for {Address} (handle {Handle}): {Error} (Category={Category}, Detail={Detail})",
writeOp.Address, phItemHandle, errorMsg, status.category, status.detail);
writeOp.CompletionSource.TrySetException(new InvalidOperationException(
$"Write failed: {errorMsg}"));
}
else
{
Logger.Debug("Write completed successfully for {Address} (handle {Handle})",
writeOp.Address, phItemHandle);
writeOp.CompletionSource.TrySetResult(true);
}
}
else
{
Logger.Debug("Write completed for {Address} (handle {Handle}) with no status",
writeOp.Address, phItemHandle);
writeOp.CompletionSource.TrySetResult(true);
}
}
finally
{
// Clean up the item after write completes
lock (_lock)
{
if (_lmxProxy != null)
{
try
{
_lmxProxy.UnAdvise(_connectionHandle, phItemHandle);
_lmxProxy.RemoveItem(_connectionHandle, phItemHandle);
}
catch (Exception ex)
{
Logger.Debug(ex, "Error cleaning up after write for handle {Handle}", phItemHandle);
}
}
}
}
}
else if (ItemStatus is { Length: > 0 })
{
var status = ItemStatus[0];
if (status.success == 0)
{
Logger.Warning("Write failed for unknown handle {Handle}: Category={Category}, Detail={Detail}",
phItemHandle, status.category, status.detail);
}
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error processing write complete for handle {Handle}", phItemHandle);
}
}
/// <summary>
/// Handles operation completion events from the MxAccess server.
/// </summary>
/// <param name="hLMXServerHandle">Server handle.</param>
/// <param name="phItemHandle">Item handle.</param>
/// <param name="ItemStatus">Status array.</param>
private void OnOperationComplete(int hLMXServerHandle, int phItemHandle, ref MXSTATUS_PROXY[] ItemStatus)
{
// Log operation completion
Logger.Debug("Operation complete for handle {Handle}", phItemHandle);
}
/// <summary>
/// Converts an integer MxAccess quality code to <see cref="Quality" />.
/// </summary>
/// <param name="mxQuality">The MxAccess quality code.</param>
/// <returns>The corresponding <see cref="Quality" /> value.</returns>
private Quality ConvertQuality(int mxQuality) => (Quality)mxQuality;
/// <summary>
/// Converts a timestamp object to <see cref="DateTime" /> in UTC.
/// </summary>
/// <param name="timestamp">The timestamp object.</param>
/// <returns>The UTC <see cref="DateTime" /> value.</returns>
private 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,132 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Private nested types for MxAccessClient to encapsulate subscription and write operation details.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Holds information about a subscription to a SCADA tag.
/// </summary>
private class SubscriptionInfo
{
/// <summary>
/// Gets or sets the address of the tag.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item handle.
/// </summary>
public int ItemHandle { get; set; }
/// <summary>
/// Gets or sets the callback for value changes.
/// </summary>
public Action<string, Vtq>? Callback { get; set; }
/// <summary>
/// Gets or sets the subscription identifier.
/// </summary>
public string SubscriptionId { get; set; } = string.Empty;
}
/// <summary>
/// Represents a handle for a subscription, allowing asynchronous disposal.
/// </summary>
private class SubscriptionHandle : IAsyncDisposable
{
private readonly MxAccessClient _client;
private readonly string _groupId;
private readonly List<string> _subscriptionIds;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionHandle" /> class.
/// </summary>
/// <param name="client">The owning <see cref="MxAccessClient" />.</param>
/// <param name="subscriptionIds">The subscription identifiers.</param>
/// <param name="groupId">The group identifier for stored subscriptions.</param>
public SubscriptionHandle(MxAccessClient client, List<string> subscriptionIds, string groupId)
{
_client = client;
_subscriptionIds = subscriptionIds;
_groupId = groupId;
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
_disposed = true;
var tasks = new List<Task>();
foreach (string? id in _subscriptionIds)
{
tasks.Add(_client.UnsubscribeInternalAsync(id));
}
await Task.WhenAll(tasks);
// Remove the stored subscription group
_client.RemoveStoredSubscription(_groupId);
}
}
/// <summary>
/// Represents a pending write operation.
/// </summary>
private class WriteOperation
{
/// <summary>
/// Gets or sets the address of the tag.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the item handle.
/// </summary>
public int ItemHandle { get; set; }
/// <summary>
/// Gets or sets the completion source for the write operation.
/// </summary>
public TaskCompletionSource<bool> CompletionSource { get; set; } = null!;
/// <summary>
/// Gets or sets the start time of the write operation.
/// </summary>
public DateTime StartTime { get; set; }
}
/// <summary>
/// Stores subscription information for automatic recreation after reconnection.
/// </summary>
private class StoredSubscription
{
/// <summary>
/// Gets or sets the addresses that were subscribed to.
/// </summary>
public List<string> Addresses { get; set; } = new();
/// <summary>
/// Gets or sets the callback for value changes.
/// </summary>
public Action<string, Vtq> Callback { get; set; } = null!;
/// <summary>
/// Gets or sets the unique identifier for this stored subscription group.
/// </summary>
public string GroupId { get; set; } = string.Empty;
}
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,153 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Subscription management for MxAccessClient to handle SCADA tag updates.
/// </summary>
public sealed partial class MxAccessClient
{
/// <summary>
/// Subscribes to a set of addresses and registers a callback for value changes.
/// </summary>
/// <param name="addresses">The collection of addresses to subscribe to.</param>
/// <param name="callback">
/// The callback to invoke when a value changes.
/// The callback receives the address and the new <see cref="Vtq" /> value.
/// </param>
/// <param name="ct">An optional <see cref="CancellationToken" /> to cancel the operation.</param>
/// <returns>
/// A <see cref="Task{IAsyncDisposable}" /> that completes with a handle to the subscription.
/// Disposing the handle will unsubscribe from all addresses.
/// </returns>
/// <exception cref="InvalidOperationException">Thrown if not connected to MxAccess.</exception>
/// <exception cref="Exception">Thrown if subscription fails for any address.</exception>
public Task<IAsyncDisposable> SubscribeAsync(IEnumerable<string> addresses, Action<string, Vtq> callback,
CancellationToken ct = default) => SubscribeInternalAsync(addresses, callback, true, ct);
/// <summary>
/// Internal subscription method that allows control over whether to store the subscription for recreation.
/// </summary>
private Task<IAsyncDisposable> SubscribeInternalAsync(IEnumerable<string> addresses,
Action<string, Vtq> callback, bool storeForRecreation, CancellationToken ct = default)
{
return Task.Run<IAsyncDisposable>(() =>
{
lock (_lock)
{
if (!IsConnected || _lmxProxy == null)
{
throw new InvalidOperationException("Not connected to MxAccess");
}
var subscriptionIds = new List<string>();
try
{
var addressList = addresses.ToList();
foreach (string? address in addressList)
{
// Add the item
var itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
// Create subscription info
string subscriptionId = Guid.NewGuid().ToString();
var subscription = new SubscriptionInfo
{
Address = address,
ItemHandle = itemHandle,
Callback = callback,
SubscriptionId = subscriptionId
};
// Store subscription
_subscriptions[subscriptionId] = subscription;
_subscriptionsByHandle[itemHandle] = subscription;
subscriptionIds.Add(subscriptionId);
// Advise the item
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
Logger.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle);
}
// Store subscription group for automatic recreation after reconnect
string groupId = Guid.NewGuid().ToString();
if (storeForRecreation)
{
_storedSubscriptions.Add(new StoredSubscription
{
Addresses = addressList,
Callback = callback,
GroupId = groupId
});
Logger.Debug(
"Stored subscription group {GroupId} with {Count} addresses for automatic recreation",
groupId, addressList.Count);
}
return new SubscriptionHandle(this, subscriptionIds, groupId);
}
catch (Exception ex)
{
// Clean up any subscriptions that were created
foreach (string? id in subscriptionIds)
{
UnsubscribeInternalAsync(id).Wait();
}
Logger.Error(ex, "Failed to subscribe to addresses");
throw;
}
}
}, ct);
}
/// <summary>
/// Unsubscribes from a subscription by its ID.
/// </summary>
/// <param name="subscriptionId">The subscription identifier.</param>
/// <returns>
/// A <see cref="Task" /> representing the asynchronous operation.
/// </returns>
private Task UnsubscribeInternalAsync(string subscriptionId)
{
return Task.Run(() =>
{
lock (_lock)
{
if (!_subscriptions.TryGetValue(subscriptionId, out SubscriptionInfo? subscription))
{
return;
}
try
{
if (_lmxProxy != null && _connectionHandle > 0)
{
_lmxProxy.UnAdvise(_connectionHandle, subscription.ItemHandle);
_lmxProxy.RemoveItem(_connectionHandle, subscription.ItemHandle);
}
_subscriptions.Remove(subscriptionId);
_subscriptionsByHandle.Remove(subscription.ItemHandle);
Logger.Debug("Unsubscribed from {Address}", subscription.Address);
}
catch (Exception ex)
{
Logger.Warning(ex, "Error unsubscribing from {Address}", subscription.Address);
}
}
});
}
}
}

View File

@@ -0,0 +1,136 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ArchestrA.MxAccess;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Implementation
{
/// <summary>
/// Implementation of <see cref="IScadaClient" /> using ArchestrA MxAccess.
/// Provides connection management, read/write operations, and subscription support for SCADA tags.
/// </summary>
public sealed partial class MxAccessClient : IScadaClient
{
private const int DefaultMaxConcurrency = 10;
private static readonly ILogger Logger = Log.ForContext<MxAccessClient>();
private readonly ConnectionConfiguration _configuration;
private readonly object _lock = new();
private readonly Dictionary<int, WriteOperation> _pendingWrites = new();
// Concurrency control for batch operations
private readonly SemaphoreSlim _readSemaphore;
// Store subscription details for automatic recreation after reconnect
private readonly List<StoredSubscription> _storedSubscriptions = new();
private readonly Dictionary<string, SubscriptionInfo> _subscriptions = new();
private readonly Dictionary<int, SubscriptionInfo> _subscriptionsByHandle = new();
private readonly SemaphoreSlim _writeSemaphore;
private int _connectionHandle;
private ConnectionState _connectionState = ConnectionState.Disconnected;
private bool _disposed;
private LMXProxyServer? _lmxProxy;
/// <summary>
/// Initializes a new instance of the <see cref="MxAccessClient" /> class.
/// </summary>
/// <param name="configuration">The connection configuration settings.</param>
public MxAccessClient(ConnectionConfiguration configuration)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
// Initialize semaphores with configurable concurrency limits
int maxConcurrency = _configuration.MaxConcurrentOperations ?? DefaultMaxConcurrency;
_readSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
_writeSemaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
}
/// <inheritdoc />
public bool IsConnected
{
get
{
lock (_lock)
{
return _lmxProxy != null && _connectionState == ConnectionState.Connected && _connectionHandle > 0;
}
}
}
/// <inheritdoc />
public ConnectionState ConnectionState
{
get
{
lock (_lock)
{
return _connectionState;
}
}
}
/// <summary>
/// Occurs when the connection state changes.
/// </summary>
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_disposed)
{
return;
}
await DisconnectAsync();
_disposed = true;
// Dispose semaphores
_readSemaphore?.Dispose();
_writeSemaphore?.Dispose();
}
/// <inheritdoc />
public void Dispose() => DisposeAsync().GetAwaiter().GetResult();
/// <summary>
/// Sets the connection state and raises the <see cref="ConnectionStateChanged" /> event.
/// </summary>
/// <param name="newState">The new connection state.</param>
/// <param name="message">Optional message describing the state change.</param>
private void SetConnectionState(ConnectionState newState, string? message = null)
{
ConnectionState previousState = _connectionState;
if (previousState == newState)
{
return;
}
_connectionState = newState;
Logger.Information("Connection state changed from {Previous} to {Current}", previousState, newState);
ConnectionStateChanged?.Invoke(this, new ConnectionStateChangedEventArgs(previousState, newState, message));
}
/// <summary>
/// Removes a stored subscription group by its ID.
/// </summary>
/// <param name="groupId">The group identifier to remove.</param>
private void RemoveStoredSubscription(string groupId)
{
lock (_lock)
{
_storedSubscriptions.RemoveAll(s => s.GroupId == groupId);
Logger.Debug("Removed stored subscription group {GroupId}", groupId);
}
}
#pragma warning disable CS0169 // Field is never used - reserved for future functionality
private string? _currentNodeName;
private string? _currentGalaxyName;
#pragma warning restore CS0169
}
}

View File

@@ -0,0 +1,592 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
using ZB.MOM.WW.LmxProxy.Host.Implementation;
using ZB.MOM.WW.LmxProxy.Host.Security;
using ZB.MOM.WW.LmxProxy.Host.Services;
using ZB.MOM.WW.LmxProxy.Host.Grpc;
using ConnectionState = ZB.MOM.WW.LmxProxy.Host.Domain.ConnectionState;
namespace ZB.MOM.WW.LmxProxy.Host
{
/// <summary>
/// Windows service that hosts the gRPC server and MxAccess client.
/// Manages lifecycle of gRPC server, SCADA client, subscription manager, and API key service.
/// </summary>
public class LmxProxyService
{
private static readonly ILogger Logger = Log.ForContext<LmxProxyService>();
private readonly LmxProxyConfiguration _configuration;
private readonly SemaphoreSlim _reconnectSemaphore = new(1, 1);
private readonly Func<LmxProxyConfiguration, IScadaClient> _scadaClientFactory;
private readonly CancellationTokenSource _shutdownCts = new();
private ApiKeyService? _apiKeyService;
private Task? _connectionMonitorTask;
private DetailedHealthCheckService? _detailedHealthCheckService;
private Server? _grpcServer;
private HealthCheckService? _healthCheckService;
private PerformanceMetrics? _performanceMetrics;
private IScadaClient? _scadaClient;
private SessionManager? _sessionManager;
private StatusReportService? _statusReportService;
private StatusWebServer? _statusWebServer;
private SubscriptionManager? _subscriptionManager;
/// <summary>
/// Initializes a new instance of the <see cref="LmxProxyService" /> class.
/// </summary>
/// <param name="configuration">Configuration settings for the service.</param>
/// <exception cref="ArgumentNullException">Thrown if configuration is null.</exception>
public LmxProxyService(LmxProxyConfiguration configuration,
Func<LmxProxyConfiguration, IScadaClient>? scadaClientFactory = null)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_scadaClientFactory = scadaClientFactory ?? (config => new MxAccessClient(config.Connection));
}
/// <summary>
/// Starts the LmxProxy service, initializing all required components and starting the gRPC server.
/// </summary>
/// <returns><c>true</c> if the service started successfully; otherwise, <c>false</c>.</returns>
public bool Start()
{
try
{
Logger.Information("Starting LmxProxy service on port {Port}", _configuration.GrpcPort);
// Validate configuration before proceeding
if (!ValidateConfiguration())
{
Logger.Error("Configuration validation failed");
return false;
}
// Check and ensure TLS certificates are valid
if (_configuration.Tls.Enabled)
{
Logger.Information("Checking TLS certificate configuration");
var tlsManager = new TlsCertificateManager(_configuration.Tls);
if (!tlsManager.EnsureCertificatesValid())
{
Logger.Error("Failed to ensure valid TLS certificates");
throw new InvalidOperationException("TLS certificate validation or generation failed");
}
Logger.Information("TLS certificates validated successfully");
}
// Create performance metrics service
_performanceMetrics = new PerformanceMetrics();
Logger.Information("Performance metrics service initialized");
// Create API key service
string apiKeyConfigPath = Path.GetFullPath(_configuration.ApiKeyConfigFile);
_apiKeyService = new ApiKeyService(apiKeyConfigPath);
Logger.Information("API key service initialized with config file: {ConfigFile}", apiKeyConfigPath);
// Create SCADA client via factory
_scadaClient = _scadaClientFactory(_configuration) ??
throw new InvalidOperationException("SCADA client factory returned null.");
// Subscribe to connection state changes
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
// Automatically connect to MxAccess on startup
try
{
Logger.Information("Connecting to MxAccess...");
Task connectTask = _scadaClient.ConnectAsync();
if (!connectTask.Wait(TimeSpan.FromSeconds(_configuration.Connection.ConnectionTimeoutSeconds)))
{
throw new TimeoutException(
$"Timeout connecting to MxAccess after {_configuration.Connection.ConnectionTimeoutSeconds} seconds");
}
Logger.Information("Successfully connected to MxAccess");
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to connect to MxAccess on startup");
throw;
}
// Start connection monitoring if auto-reconnect is enabled
if (_configuration.Connection.AutoReconnect)
{
_connectionMonitorTask = Task.Run(() => MonitorConnectionAsync(_shutdownCts.Token));
Logger.Information("Connection monitoring started with {Interval} second interval",
_configuration.Connection.MonitorIntervalSeconds);
}
// Create subscription manager with configuration
_subscriptionManager = new SubscriptionManager(_scadaClient, _configuration.Subscription);
// Create session manager for tracking client sessions
_sessionManager = new SessionManager();
Logger.Information("Session manager initialized");
// Create health check services
_healthCheckService = new HealthCheckService(_scadaClient, _subscriptionManager, _performanceMetrics);
_detailedHealthCheckService = new DetailedHealthCheckService(_scadaClient);
Logger.Information("Health check services initialized");
// Create status report service and web server
_statusReportService = new StatusReportService(
_scadaClient,
_subscriptionManager,
_performanceMetrics,
_healthCheckService,
_detailedHealthCheckService);
_statusWebServer = new StatusWebServer(_configuration.WebServer, _statusReportService);
Logger.Information("Status web server initialized");
// Create gRPC service with session manager and performance metrics
var scadaService = new ScadaGrpcService(_scadaClient, _subscriptionManager, _sessionManager, _performanceMetrics);
// Create API key interceptor
var apiKeyInterceptor = new ApiKeyInterceptor(_apiKeyService);
// Configure server credentials based on TLS configuration
ServerCredentials serverCredentials;
if (_configuration.Tls.Enabled)
{
serverCredentials = CreateTlsCredentials(_configuration.Tls);
Logger.Information("TLS enabled for gRPC server");
}
else
{
serverCredentials = ServerCredentials.Insecure;
Logger.Warning("gRPC server running without TLS encryption - not recommended for production");
}
// Configure and start gRPC server with interceptor
_grpcServer = new Server
{
Services = { ScadaService.BindService(scadaService).Intercept(apiKeyInterceptor) },
Ports = { new ServerPort("0.0.0.0", _configuration.GrpcPort, serverCredentials) }
};
_grpcServer.Start();
string securityMode = _configuration.Tls.Enabled ? "TLS/SSL" : "INSECURE";
Logger.Information("LmxProxy service started successfully on port {Port} ({SecurityMode})",
_configuration.GrpcPort, securityMode);
Logger.Information("gRPC server listening on 0.0.0.0:{Port}", _configuration.GrpcPort);
// Start status web server
if (_statusWebServer != null && !_statusWebServer.Start())
{
Logger.Warning("Failed to start status web server, continuing without it");
}
return true;
}
catch (Exception ex)
{
Logger.Fatal(ex, "Failed to start LmxProxy service");
return false;
}
}
/// <summary>
/// Stops the LmxProxy service, shutting down the gRPC server and disposing all resources.
/// </summary>
/// <returns><c>true</c> if the service stopped successfully; otherwise, <c>false</c>.</returns>
public bool Stop()
{
try
{
Logger.Information("Stopping LmxProxy service");
_shutdownCts.Cancel();
// Stop connection monitoring
if (_connectionMonitorTask != null)
{
try
{
_connectionMonitorTask.Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Logger.Warning(ex, "Error stopping connection monitor");
}
}
// Shutdown gRPC server
if (_grpcServer != null)
{
Logger.Information("Shutting down gRPC server");
Task? shutdownTask = _grpcServer.ShutdownAsync();
// Wait up to 10 seconds for graceful shutdown
if (!shutdownTask.Wait(TimeSpan.FromSeconds(10)))
{
Logger.Warning("gRPC server shutdown timeout, forcing kill");
_grpcServer.KillAsync().Wait(TimeSpan.FromSeconds(5));
}
_grpcServer = null;
}
// Stop status web server
if (_statusWebServer != null)
{
Logger.Information("Stopping status web server");
try
{
_statusWebServer.Stop();
_statusWebServer.Dispose();
_statusWebServer = null;
}
catch (Exception ex)
{
Logger.Warning(ex, "Error stopping status web server");
}
}
// Dispose status report service
if (_statusReportService != null)
{
Logger.Information("Disposing status report service");
_statusReportService = null;
}
// Dispose health check services
if (_detailedHealthCheckService != null)
{
Logger.Information("Disposing detailed health check service");
_detailedHealthCheckService = null;
}
if (_healthCheckService != null)
{
Logger.Information("Disposing health check service");
_healthCheckService = null;
}
// Dispose subscription manager
if (_subscriptionManager != null)
{
Logger.Information("Disposing subscription manager");
_subscriptionManager.Dispose();
_subscriptionManager = null;
}
// Dispose session manager
if (_sessionManager != null)
{
Logger.Information("Disposing session manager");
_sessionManager.Dispose();
_sessionManager = null;
}
// Dispose API key service
if (_apiKeyService != null)
{
Logger.Information("Disposing API key service");
_apiKeyService.Dispose();
_apiKeyService = null;
}
// Dispose performance metrics
if (_performanceMetrics != null)
{
Logger.Information("Disposing performance metrics service");
_performanceMetrics.Dispose();
_performanceMetrics = null;
}
// Disconnect and dispose SCADA client
if (_scadaClient != null)
{
Logger.Information("Disconnecting SCADA client");
// Unsubscribe from events
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
try
{
Task disconnectTask = _scadaClient.DisconnectAsync();
if (!disconnectTask.Wait(TimeSpan.FromSeconds(10)))
{
Logger.Warning("SCADA client disconnect timeout");
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Error disconnecting SCADA client");
}
try
{
Task? disposeTask = _scadaClient.DisposeAsync().AsTask();
if (!disposeTask.Wait(TimeSpan.FromSeconds(5)))
{
Logger.Warning("SCADA client dispose timeout");
}
}
catch (Exception ex)
{
Logger.Warning(ex, "Error disposing SCADA client");
}
_scadaClient = null;
}
Logger.Information("LmxProxy service stopped successfully");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error stopping LmxProxy service");
return false;
}
}
/// <summary>
/// Pauses the LmxProxy service. No operation is performed except logging.
/// </summary>
public void Pause() => Logger.Information("LmxProxy service paused");
/// <summary>
/// Continues the LmxProxy service after a pause. No operation is performed except logging.
/// </summary>
public void Continue() => Logger.Information("LmxProxy service continued");
/// <summary>
/// Requests shutdown of the LmxProxy service and stops all components.
/// </summary>
public void Shutdown()
{
Logger.Information("LmxProxy service shutdown requested");
Stop();
}
/// <summary>
/// Handles connection state changes from the SCADA client.
/// </summary>
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Logger.Information("MxAccess connection state changed from {Previous} to {Current}",
e.PreviousState, e.CurrentState);
if (e.CurrentState == ConnectionState.Disconnected &&
e.PreviousState == ConnectionState.Connected)
{
Logger.Warning("MxAccess connection lost. Automatic reconnection will be attempted.");
}
}
/// <summary>
/// Monitors the connection and attempts to reconnect when disconnected.
/// </summary>
private async Task MonitorConnectionAsync(CancellationToken cancellationToken)
{
Logger.Information("Starting connection monitor");
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(_configuration.Connection.MonitorIntervalSeconds),
cancellationToken);
if (_scadaClient != null && !_scadaClient.IsConnected && !cancellationToken.IsCancellationRequested)
{
await _reconnectSemaphore.WaitAsync(cancellationToken);
try
{
if (_scadaClient != null && !_scadaClient.IsConnected)
{
Logger.Information("Attempting to reconnect to MxAccess...");
try
{
await _scadaClient.ConnectAsync(cancellationToken);
Logger.Information("Successfully reconnected to MxAccess");
}
catch (Exception ex)
{
Logger.Warning(ex,
"Failed to reconnect to MxAccess. Will retry in {Interval} seconds.",
_configuration.Connection.MonitorIntervalSeconds);
}
}
}
finally
{
_reconnectSemaphore.Release();
}
}
}
catch (OperationCanceledException)
{
// Expected when shutting down
break;
}
catch (Exception ex)
{
Logger.Error(ex, "Error in connection monitor");
}
}
Logger.Information("Connection monitor stopped");
}
/// <summary>
/// Creates TLS server credentials from configuration
/// </summary>
private static ServerCredentials CreateTlsCredentials(TlsConfiguration tlsConfig)
{
try
{
// Read certificate and key files
string serverCert = File.ReadAllText(tlsConfig.ServerCertificatePath);
string serverKey = File.ReadAllText(tlsConfig.ServerKeyPath);
var keyCertPairs = new List<KeyCertificatePair>
{
new(serverCert, serverKey)
};
// Configure client certificate requirements
if (tlsConfig.RequireClientCertificate && !string.IsNullOrWhiteSpace(tlsConfig.ClientCaCertificatePath))
{
string clientCaCert = File.ReadAllText(tlsConfig.ClientCaCertificatePath);
return new SslServerCredentials(
keyCertPairs,
clientCaCert,
tlsConfig.CheckCertificateRevocation
? SslClientCertificateRequestType.RequestAndRequireAndVerify
: SslClientCertificateRequestType.RequestAndRequireButDontVerify);
}
if (tlsConfig.RequireClientCertificate)
{
// Require client certificate but no CA specified - use system CA
return new SslServerCredentials(
keyCertPairs,
null,
SslClientCertificateRequestType.RequestAndRequireAndVerify);
}
// No client certificate required
return new SslServerCredentials(keyCertPairs);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create TLS credentials");
throw new InvalidOperationException("Failed to configure TLS for gRPC server", ex);
}
}
/// <summary>
/// Validates the service configuration and returns false if any critical issues are found
/// </summary>
private bool ValidateConfiguration()
{
try
{
// Validate gRPC port
if (_configuration.GrpcPort <= 0 || _configuration.GrpcPort > 65535)
{
Logger.Error("Invalid gRPC port: {Port}. Port must be between 1 and 65535",
_configuration.GrpcPort);
return false;
}
// Validate API key configuration file
if (string.IsNullOrWhiteSpace(_configuration.ApiKeyConfigFile))
{
Logger.Error("API key configuration file path is null or empty");
return false;
}
// Check if API key file exists or can be created
string apiKeyPath = Path.GetFullPath(_configuration.ApiKeyConfigFile);
string? apiKeyDirectory = Path.GetDirectoryName(apiKeyPath);
if (!string.IsNullOrEmpty(apiKeyDirectory) && !Directory.Exists(apiKeyDirectory))
{
try
{
Directory.CreateDirectory(apiKeyDirectory);
}
catch (Exception ex)
{
Logger.Error(ex, "Cannot create directory for API key file: {Directory}", apiKeyDirectory);
return false;
}
}
// If API key file exists, validate it can be read
if (File.Exists(apiKeyPath))
{
try
{
string content = File.ReadAllText(apiKeyPath);
if (!string.IsNullOrWhiteSpace(content))
{
// Try to parse as JSON to validate format
JsonDocument.Parse(content);
}
}
catch (Exception ex)
{
Logger.Error(ex, "API key configuration file is invalid or unreadable: {FilePath}", apiKeyPath);
return false;
}
}
// Validate TLS configuration if enabled
if (_configuration.Tls.Enabled)
{
if (!_configuration.Tls.Validate())
{
Logger.Error("TLS configuration validation failed");
return false;
}
}
// Validate web server configuration if enabled
if (_configuration.WebServer.Enabled)
{
if (_configuration.WebServer.Port <= 0 || _configuration.WebServer.Port > 65535)
{
Logger.Error("Invalid web server port: {Port}. Port must be between 1 and 65535",
_configuration.WebServer.Port);
return false;
}
// Check for port conflicts
if (_configuration.WebServer.Port == _configuration.GrpcPort)
{
Logger.Error("Web server port {WebPort} conflicts with gRPC port {GrpcPort}",
_configuration.WebServer.Port, _configuration.GrpcPort);
return false;
}
}
Logger.Information("Configuration validation passed");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error during configuration validation");
return false;
}
}
}
}

View File

@@ -0,0 +1,87 @@
using System;
using System.IO;
using Microsoft.Extensions.Configuration;
using Serilog;
using Topshelf;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host
{
internal class Program
{
private static void Main(string[] args)
{
// Build configuration
IConfigurationRoot? configuration = new ConfigurationBuilder()
.SetBasePath(Directory.GetCurrentDirectory())
.AddJsonFile("appsettings.json", true, true)
.AddEnvironmentVariables()
.Build();
// Configure Serilog from appsettings.json
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
try
{
Log.Information("Starting ZB.MOM.WW.LmxProxy.Host");
// Load configuration
var config = new LmxProxyConfiguration();
configuration.Bind(config);
// Validate configuration
if (!ConfigurationValidator.ValidateAndLog(config))
{
Log.Fatal("Configuration validation failed. Please check the configuration and try again.");
Environment.ExitCode = 1;
return;
}
// Configure and run the Windows service using TopShelf
TopshelfExitCode exitCode = HostFactory.Run(hostConfig =>
{
hostConfig.Service<LmxProxyService>(serviceConfig =>
{
serviceConfig.ConstructUsing(() => new LmxProxyService(config));
serviceConfig.WhenStarted(service => service.Start());
serviceConfig.WhenStopped(service => service.Stop());
serviceConfig.WhenPaused(service => service.Pause());
serviceConfig.WhenContinued(service => service.Continue());
serviceConfig.WhenShutdown(service => service.Shutdown());
});
hostConfig.UseSerilog(Log.Logger);
hostConfig.SetServiceName("ZB.MOM.WW.LmxProxy.Host");
hostConfig.SetDisplayName("SCADA Bridge LMX Proxy");
hostConfig.SetDescription("Provides gRPC access to Archestra MxAccess for SCADA Bridge");
hostConfig.StartAutomatically();
hostConfig.EnableServiceRecovery(recoveryConfig =>
{
recoveryConfig.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes);
recoveryConfig.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes);
recoveryConfig.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes);
recoveryConfig.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays);
});
hostConfig.OnException(ex => { Log.Fatal(ex, "Unhandled exception in service"); });
});
Log.Information("Service exited with code: {ExitCode}", exitCode);
Environment.ExitCode = (int)exitCode;
}
catch (Exception ex)
{
Log.Fatal(ex, "Failed to start service");
Environment.ExitCode = 1;
}
finally
{
Log.CloseAndFlush();
}
}
}
}

View File

@@ -0,0 +1,49 @@
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Represents an API key with associated permissions
/// </summary>
public class ApiKey
{
/// <summary>
/// The API key value
/// </summary>
public string Key { get; set; } = string.Empty;
/// <summary>
/// Description of what this API key is used for
/// </summary>
public string Description { get; set; } = string.Empty;
/// <summary>
/// The role assigned to this API key
/// </summary>
public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly;
/// <summary>
/// Whether this API key is enabled
/// </summary>
public bool Enabled { get; set; } = true;
/// <summary>
/// Checks if the API key is valid
/// </summary>
public bool IsValid() => Enabled;
}
/// <summary>
/// API key roles
/// </summary>
public enum ApiKeyRole
{
/// <summary>
/// Can only read data
/// </summary>
ReadOnly,
/// <summary>
/// Can read and write data
/// </summary>
ReadWrite
}
}

View File

@@ -0,0 +1,15 @@
using System.Collections.Generic;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Configuration for API keys loaded from file
/// </summary>
public class ApiKeyConfiguration
{
/// <summary>
/// List of API keys
/// </summary>
public List<ApiKey> ApiKeys { get; set; } = new();
}
}

View File

@@ -0,0 +1,168 @@
using System;
using System.Linq;
using System.Threading.Tasks;
using Grpc.Core;
using Grpc.Core.Interceptors;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// gRPC interceptor for API key authentication.
/// Validates API keys for incoming requests and enforces role-based access control.
/// </summary>
public class ApiKeyInterceptor : Interceptor
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyInterceptor>();
/// <summary>
/// List of gRPC method names that require write access.
/// </summary>
private static readonly string[] WriteMethodNames =
{
"Write",
"WriteBatch",
"WriteBatchAndWait"
};
private readonly ApiKeyService _apiKeyService;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyInterceptor" /> class.
/// </summary>
/// <param name="apiKeyService">The API key service used for validation.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="apiKeyService" /> is null.</exception>
public ApiKeyInterceptor(ApiKeyService apiKeyService)
{
_apiKeyService = apiKeyService ?? throw new ArgumentNullException(nameof(apiKeyService));
}
/// <summary>
/// Handles unary gRPC calls, validating API key and enforcing permissions.
/// </summary>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <typeparam name="TResponse">The response type.</typeparam>
/// <param name="request">The request message.</param>
/// <param name="context">The server call context.</param>
/// <param name="continuation">The continuation delegate.</param>
/// <returns>The response message.</returns>
/// <exception cref="RpcException">Thrown if authentication or authorization fails.</exception>
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
TRequest request,
ServerCallContext context,
UnaryServerMethod<TRequest, TResponse> continuation)
{
string apiKey = GetApiKeyFromContext(context);
string methodName = GetMethodName(context.Method);
if (string.IsNullOrEmpty(apiKey))
{
Logger.Warning("Missing API key for method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
}
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Logger.Warning("Invalid API key for method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
}
// Check if method requires write access
if (IsWriteMethod(methodName) && key.Role != ApiKeyRole.ReadWrite)
{
Logger.Warning("Insufficient permissions for method {Method} with API key {Description}",
context.Method, key.Description);
throw new RpcException(new Status(StatusCode.PermissionDenied,
"API key does not have write permissions"));
}
// Add API key info to context items for use in service methods
context.UserState["ApiKey"] = key;
Logger.Debug("Authorized method {Method} for API key {Description}",
context.Method, key.Description);
return await continuation(request, context);
}
/// <summary>
/// Handles server streaming gRPC calls, validating API key and enforcing permissions.
/// </summary>
/// <typeparam name="TRequest">The request type.</typeparam>
/// <typeparam name="TResponse">The response type.</typeparam>
/// <param name="request">The request message.</param>
/// <param name="responseStream">The response stream writer.</param>
/// <param name="context">The server call context.</param>
/// <param name="continuation">The continuation delegate.</param>
/// <returns>A task representing the asynchronous operation.</returns>
/// <exception cref="RpcException">Thrown if authentication fails.</exception>
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
TRequest request,
IServerStreamWriter<TResponse> responseStream,
ServerCallContext context,
ServerStreamingServerMethod<TRequest, TResponse> continuation)
{
string apiKey = GetApiKeyFromContext(context);
if (string.IsNullOrEmpty(apiKey))
{
Logger.Warning("Missing API key for streaming method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "API key is required"));
}
ApiKey? key = _apiKeyService.ValidateApiKey(apiKey);
if (key == null)
{
Logger.Warning("Invalid API key for streaming method {Method} from {Peer}",
context.Method, context.Peer);
throw new RpcException(new Status(StatusCode.Unauthenticated, "Invalid API key"));
}
// Add API key info to context items
context.UserState["ApiKey"] = key;
Logger.Debug("Authorized streaming method {Method} for API key {Description}",
context.Method, key.Description);
await continuation(request, responseStream, context);
}
/// <summary>
/// Extracts the API key from the gRPC request headers.
/// </summary>
/// <param name="context">The server call context.</param>
/// <returns>The API key value, or an empty string if not found.</returns>
private static string GetApiKeyFromContext(ServerCallContext context)
{
// Check for API key in metadata (headers)
Metadata.Entry? entry = context.RequestHeaders.FirstOrDefault(e =>
e.Key.Equals("x-api-key", StringComparison.OrdinalIgnoreCase));
return entry?.Value ?? string.Empty;
}
/// <summary>
/// Gets the method name from the full gRPC method string.
/// </summary>
/// <param name="method">The full method string (e.g., /package.Service/Method).</param>
/// <returns>The method name.</returns>
private static string GetMethodName(string method)
{
// Method format is /package.Service/Method
int lastSlash = method.LastIndexOf('/');
return lastSlash >= 0 ? method.Substring(lastSlash + 1) : method;
}
/// <summary>
/// Determines whether the specified method name requires write access.
/// </summary>
/// <param name="methodName">The method name.</param>
/// <returns><c>true</c> if the method requires write access; otherwise, <c>false</c>.</returns>
private static bool IsWriteMethod(string methodName) =>
WriteMethodNames.Contains(methodName, StringComparer.OrdinalIgnoreCase);
}
}

View File

@@ -0,0 +1,305 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Service for managing API keys with file-based storage.
/// Handles validation, role checking, and automatic reload on file changes.
/// </summary>
public class ApiKeyService : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<ApiKeyService>();
private readonly ConcurrentDictionary<string, ApiKey> _apiKeys;
private readonly string _configFilePath;
private readonly SemaphoreSlim _reloadLock = new(1, 1);
private bool _disposed;
private FileSystemWatcher? _fileWatcher;
private DateTime _lastReloadTime = DateTime.MinValue;
/// <summary>
/// Initializes a new instance of the <see cref="ApiKeyService" /> class.
/// </summary>
/// <param name="configFilePath">The path to the API key configuration file.</param>
/// <exception cref="ArgumentNullException">Thrown if <paramref name="configFilePath" /> is null.</exception>
public ApiKeyService(string configFilePath)
{
_configFilePath = configFilePath ?? throw new ArgumentNullException(nameof(configFilePath));
_apiKeys = new ConcurrentDictionary<string, ApiKey>();
InitializeFileWatcher();
LoadConfiguration();
}
/// <summary>
/// Disposes the <see cref="ApiKeyService" /> and releases resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_fileWatcher?.Dispose();
_reloadLock?.Dispose();
Logger.Information("API key service disposed");
}
/// <summary>
/// Validates an API key and returns its details if valid.
/// </summary>
/// <param name="apiKey">The API key value to validate.</param>
/// <returns>The <see cref="ApiKey" /> if valid; otherwise, <c>null</c>.</returns>
public ApiKey? ValidateApiKey(string apiKey)
{
if (string.IsNullOrWhiteSpace(apiKey))
{
return null;
}
if (_apiKeys.TryGetValue(apiKey, out ApiKey? key) && key.IsValid())
{
Logger.Debug("API key validated successfully for {Description}", key.Description);
return key;
}
Logger.Warning("Invalid or expired API key attempted");
return null;
}
/// <summary>
/// Checks if an API key has the specified role.
/// </summary>
/// <param name="apiKey">The API key value.</param>
/// <param name="requiredRole">The required <see cref="ApiKeyRole" />.</param>
/// <returns><c>true</c> if the API key has the required role; otherwise, <c>false</c>.</returns>
public bool HasRole(string apiKey, ApiKeyRole requiredRole)
{
ApiKey? key = ValidateApiKey(apiKey);
if (key == null)
{
return false;
}
// ReadWrite role has access to everything
if (key.Role == ApiKeyRole.ReadWrite)
{
return true;
}
// ReadOnly role only has access to ReadOnly operations
return requiredRole == ApiKeyRole.ReadOnly;
}
/// <summary>
/// Initializes the file system watcher for the API key configuration file.
/// </summary>
private void InitializeFileWatcher()
{
string? directory = Path.GetDirectoryName(_configFilePath);
string? fileName = Path.GetFileName(_configFilePath);
if (string.IsNullOrEmpty(directory) || string.IsNullOrEmpty(fileName))
{
Logger.Warning("Invalid config file path, file watching disabled");
return;
}
try
{
_fileWatcher = new FileSystemWatcher(directory, fileName)
{
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size | NotifyFilters.CreationTime,
EnableRaisingEvents = true
};
_fileWatcher.Changed += OnFileChanged;
_fileWatcher.Created += OnFileChanged;
_fileWatcher.Renamed += OnFileRenamed;
Logger.Information("File watcher initialized for {FilePath}", _configFilePath);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to initialize file watcher for {FilePath}", _configFilePath);
}
}
/// <summary>
/// Handles file change events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="FileSystemEventArgs" /> instance containing event data.</param>
private void OnFileChanged(object sender, FileSystemEventArgs e)
{
if (e.ChangeType == WatcherChangeTypes.Changed || e.ChangeType == WatcherChangeTypes.Created)
{
Logger.Information("API key configuration file changed, reloading");
Task.Run(() => ReloadConfigurationAsync());
}
}
/// <summary>
/// Handles file rename events for the configuration file.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The <see cref="RenamedEventArgs" /> instance containing event data.</param>
private void OnFileRenamed(object sender, RenamedEventArgs e)
{
if (e.FullPath.Equals(_configFilePath, StringComparison.OrdinalIgnoreCase))
{
Logger.Information("API key configuration file renamed, reloading");
Task.Run(() => ReloadConfigurationAsync());
}
}
/// <summary>
/// Asynchronously reloads the API key configuration from file.
/// Debounces rapid file changes to avoid excessive reloads.
/// </summary>
private async Task ReloadConfigurationAsync()
{
// Debounce rapid file changes
TimeSpan timeSinceLastReload = DateTime.UtcNow - _lastReloadTime;
if (timeSinceLastReload < TimeSpan.FromSeconds(1))
{
await Task.Delay(TimeSpan.FromSeconds(1) - timeSinceLastReload);
}
await _reloadLock.WaitAsync();
try
{
LoadConfiguration();
_lastReloadTime = DateTime.UtcNow;
}
finally
{
_reloadLock.Release();
}
}
/// <summary>
/// Loads the API key configuration from file.
/// If the file does not exist, creates a default configuration.
/// </summary>
private void LoadConfiguration()
{
try
{
if (!File.Exists(_configFilePath))
{
Logger.Warning("API key configuration file not found at {FilePath}, creating default",
_configFilePath);
CreateDefaultConfiguration();
return;
}
string json = File.ReadAllText(_configFilePath);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
ReadCommentHandling = JsonCommentHandling.Skip
};
options.Converters.Add(new JsonStringEnumConverter());
ApiKeyConfiguration? config = JsonSerializer.Deserialize<ApiKeyConfiguration>(json, options);
if (config?.ApiKeys == null || !config.ApiKeys.Any())
{
Logger.Warning("No API keys found in configuration file");
return;
}
// Clear existing keys and load new ones
_apiKeys.Clear();
foreach (ApiKey? apiKey in config.ApiKeys)
{
if (string.IsNullOrWhiteSpace(apiKey.Key))
{
Logger.Warning("Skipping API key with empty key value");
continue;
}
if (_apiKeys.TryAdd(apiKey.Key, apiKey))
{
Logger.Information("Loaded API key: {Description} with role {Role}",
apiKey.Description, apiKey.Role);
}
else
{
Logger.Warning("Duplicate API key found: {Description}", apiKey.Description);
}
}
Logger.Information("Loaded {Count} API keys from configuration", _apiKeys.Count);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to load API key configuration from {FilePath}", _configFilePath);
}
}
/// <summary>
/// Creates a default API key configuration file with sample keys.
/// </summary>
private void CreateDefaultConfiguration()
{
try
{
var defaultConfig = new ApiKeyConfiguration
{
ApiKeys = new List<ApiKey>
{
new()
{
Key = Guid.NewGuid().ToString("N"),
Description = "Default read-only API key",
Role = ApiKeyRole.ReadOnly,
Enabled = true
},
new()
{
Key = Guid.NewGuid().ToString("N"),
Description = "Default read-write API key",
Role = ApiKeyRole.ReadWrite,
Enabled = true
}
}
};
string? json = JsonSerializer.Serialize(defaultConfig, new JsonSerializerOptions
{
WriteIndented = true
});
string? directory = Path.GetDirectoryName(_configFilePath);
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
{
Directory.CreateDirectory(directory);
}
File.WriteAllText(_configFilePath, json);
Logger.Information("Created default API key configuration at {FilePath}", _configFilePath);
// Load the created configuration
LoadConfiguration();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to create default API key configuration");
}
}
}
}

View File

@@ -0,0 +1,329 @@
using System;
using System.IO;
using System.Net;
using System.Security.Cryptography;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Security
{
/// <summary>
/// Manages TLS certificates for the LmxProxy service, including generation and validation
/// </summary>
public class TlsCertificateManager
{
private static readonly ILogger Logger = Log.ForContext<TlsCertificateManager>();
private readonly TlsConfiguration _tlsConfiguration;
public TlsCertificateManager(TlsConfiguration tlsConfiguration)
{
_tlsConfiguration = tlsConfiguration ?? throw new ArgumentNullException(nameof(tlsConfiguration));
}
/// <summary>
/// Checks TLS certificate status and creates new certificates if needed
/// </summary>
/// <returns>True if certificates are valid or were successfully created</returns>
public bool EnsureCertificatesValid()
{
if (!_tlsConfiguration.Enabled)
{
Logger.Information("TLS is disabled, skipping certificate check");
return true;
}
try
{
// Check if certificate files exist
bool certificateExists = File.Exists(_tlsConfiguration.ServerCertificatePath);
bool keyExists = File.Exists(_tlsConfiguration.ServerKeyPath);
if (!certificateExists || !keyExists)
{
Logger.Warning("TLS certificate or key not found, generating new certificate");
return GenerateNewCertificate();
}
// Check certificate expiration
if (IsCertificateExpiringSoon(_tlsConfiguration.ServerCertificatePath))
{
Logger.Warning("TLS certificate is expiring within the next year, generating new certificate");
return GenerateNewCertificate();
}
Logger.Information("TLS certificate is valid");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking TLS certificates");
return false;
}
}
/// <summary>
/// Checks if a certificate is expiring within the next year
/// </summary>
private bool IsCertificateExpiringSoon(string certificatePath)
{
try
{
string certPem = File.ReadAllText(certificatePath);
byte[] certBytes = GetBytesFromPem(certPem, "CERTIFICATE");
using var cert = new X509Certificate2(certBytes);
DateTime expirationDate = cert.NotAfter;
double daysUntilExpiration = (expirationDate - DateTime.Now).TotalDays;
Logger.Information("Certificate expires on {ExpirationDate} ({DaysUntilExpiration:F0} days from now)",
expirationDate, daysUntilExpiration);
// Check if expiring within the next year (365 days)
return daysUntilExpiration <= 365;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking certificate expiration");
// If we can't check expiration, assume it needs renewal
return true;
}
}
/// <summary>
/// Generates a new self-signed certificate
/// </summary>
private bool GenerateNewCertificate()
{
try
{
Logger.Information("Generating new self-signed TLS certificate");
// Ensure directory exists
string? certDir = Path.GetDirectoryName(_tlsConfiguration.ServerCertificatePath);
if (!string.IsNullOrEmpty(certDir) && !Directory.Exists(certDir))
{
Directory.CreateDirectory(certDir);
Logger.Information("Created certificate directory: {Directory}", certDir);
}
// Generate a new self-signed certificate
using var rsa = RSA.Create(2048);
var request = new CertificateRequest(
"CN=LmxProxy, O=SCADA Bridge, C=US",
rsa,
HashAlgorithmName.SHA256,
RSASignaturePadding.Pkcs1);
// Add certificate extensions
request.CertificateExtensions.Add(
new X509BasicConstraintsExtension(false, false, 0, false));
request.CertificateExtensions.Add(
new X509KeyUsageExtension(
X509KeyUsageFlags.DigitalSignature | X509KeyUsageFlags.KeyEncipherment,
false));
request.CertificateExtensions.Add(
new X509EnhancedKeyUsageExtension(
new OidCollection
{
new Oid("1.3.6.1.5.5.7.3.1") // Server Authentication
},
false));
// Add Subject Alternative Names
var sanBuilder = new SubjectAlternativeNameBuilder();
sanBuilder.AddDnsName("localhost");
sanBuilder.AddDnsName(Environment.MachineName);
sanBuilder.AddIpAddress(IPAddress.Loopback);
sanBuilder.AddIpAddress(IPAddress.IPv6Loopback);
request.CertificateExtensions.Add(sanBuilder.Build());
// Create the certificate with 2-year validity
DateTimeOffset notBefore = DateTimeOffset.Now.AddDays(-1);
DateTimeOffset notAfter = DateTimeOffset.Now.AddYears(2);
using X509Certificate2? cert = request.CreateSelfSigned(notBefore, notAfter);
// Export certificate to PEM format
string certPem = ExportCertificateToPem(cert);
File.WriteAllText(_tlsConfiguration.ServerCertificatePath, certPem);
Logger.Information("Saved certificate to {Path}", _tlsConfiguration.ServerCertificatePath);
// Export private key to PEM format
string keyPem = ExportPrivateKeyToPem(rsa);
File.WriteAllText(_tlsConfiguration.ServerKeyPath, keyPem);
Logger.Information("Saved private key to {Path}", _tlsConfiguration.ServerKeyPath);
// If client CA path is specified and doesn't exist, create it
if (!string.IsNullOrWhiteSpace(_tlsConfiguration.ClientCaCertificatePath) &&
!File.Exists(_tlsConfiguration.ClientCaCertificatePath))
{
// For self-signed certificates, the CA cert is the same as the server cert
File.WriteAllText(_tlsConfiguration.ClientCaCertificatePath, certPem);
Logger.Information("Saved CA certificate to {Path}", _tlsConfiguration.ClientCaCertificatePath);
}
Logger.Information("Successfully generated new TLS certificate valid until {NotAfter}", notAfter);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to generate new TLS certificate");
return false;
}
}
/// <summary>
/// Exports a certificate to PEM format
/// </summary>
private static string ExportCertificateToPem(X509Certificate2 cert)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN CERTIFICATE-----");
builder.AppendLine(Convert.ToBase64String(cert.Export(X509ContentType.Cert),
Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END CERTIFICATE-----");
return builder.ToString();
}
/// <summary>
/// Exports an RSA private key to PEM format
/// </summary>
private static string ExportPrivateKeyToPem(RSA rsa)
{
var builder = new StringBuilder();
builder.AppendLine("-----BEGIN RSA PRIVATE KEY-----");
// For .NET Framework 4.8, we need to use the older export method
RSAParameters parameters = rsa.ExportParameters(true);
byte[] keyBytes = EncodeRSAPrivateKey(parameters);
builder.AppendLine(Convert.ToBase64String(keyBytes, Base64FormattingOptions.InsertLineBreaks));
builder.AppendLine("-----END RSA PRIVATE KEY-----");
return builder.ToString();
}
/// <summary>
/// Encodes RSA parameters to PKCS#1 format for .NET Framework 4.8
/// </summary>
private static byte[] EncodeRSAPrivateKey(RSAParameters parameters)
{
using (var stream = new MemoryStream())
using (var writer = new BinaryWriter(stream))
{
// Write version
writer.Write((byte)0x02); // INTEGER
writer.Write((byte)0x01); // Length
writer.Write((byte)0x00); // Version
// Write modulus
WriteIntegerBytes(writer, parameters.Modulus);
// Write public exponent
WriteIntegerBytes(writer, parameters.Exponent);
// Write private exponent
WriteIntegerBytes(writer, parameters.D);
// Write prime1
WriteIntegerBytes(writer, parameters.P);
// Write prime2
WriteIntegerBytes(writer, parameters.Q);
// Write exponent1
WriteIntegerBytes(writer, parameters.DP);
// Write exponent2
WriteIntegerBytes(writer, parameters.DQ);
// Write coefficient
WriteIntegerBytes(writer, parameters.InverseQ);
byte[] innerBytes = stream.ToArray();
// Create SEQUENCE wrapper
using (var finalStream = new MemoryStream())
using (var finalWriter = new BinaryWriter(finalStream))
{
finalWriter.Write((byte)0x30); // SEQUENCE
WriteLength(finalWriter, innerBytes.Length);
finalWriter.Write(innerBytes);
return finalStream.ToArray();
}
}
}
private static void WriteIntegerBytes(BinaryWriter writer, byte[] bytes)
{
if (bytes == null)
{
bytes = new byte[] { 0 };
}
writer.Write((byte)0x02); // INTEGER
if (bytes[0] >= 0x80)
{
// Add padding byte for positive number
WriteLength(writer, bytes.Length + 1);
writer.Write((byte)0x00);
writer.Write(bytes);
}
else
{
WriteLength(writer, bytes.Length);
writer.Write(bytes);
}
}
private static void WriteLength(BinaryWriter writer, int length)
{
if (length < 0x80)
{
writer.Write((byte)length);
}
else if (length <= 0xFF)
{
writer.Write((byte)0x81);
writer.Write((byte)length);
}
else
{
writer.Write((byte)0x82);
writer.Write((byte)(length >> 8));
writer.Write((byte)(length & 0xFF));
}
}
/// <summary>
/// Extracts bytes from PEM format
/// </summary>
private static byte[] GetBytesFromPem(string pem, string section)
{
string header = $"-----BEGIN {section}-----";
string footer = $"-----END {section}-----";
int start = pem.IndexOf(header, StringComparison.Ordinal);
if (start < 0)
{
throw new InvalidOperationException($"PEM {section} header not found");
}
start += header.Length;
int end = pem.IndexOf(footer, start, StringComparison.Ordinal);
if (end < 0)
{
throw new InvalidOperationException($"PEM {section} footer not found");
}
// Use Substring instead of range syntax for .NET Framework 4.8 compatibility
string base64 = pem.Substring(start, end - start).Replace("\r", "").Replace("\n", "");
return Convert.FromBase64String(base64);
}
}
}

View File

@@ -0,0 +1,189 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Health check service for monitoring LmxProxy health
/// </summary>
public class HealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
public HealthCheckService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
}
public Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
try
{
// Check SCADA connection
bool isConnected = _scadaClient.IsConnected;
ConnectionState connectionState = _scadaClient.ConnectionState;
data["scada_connected"] = isConnected;
data["scada_connection_state"] = connectionState.ToString();
// Get subscription statistics
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
data["total_clients"] = subscriptionStats.TotalClients;
data["total_tags"] = subscriptionStats.TotalTags;
// Get performance metrics
IReadOnlyDictionary<string, OperationMetrics> metrics = _performanceMetrics.GetAllMetrics();
long totalOperations = 0L;
double averageSuccessRate = 0.0;
foreach (OperationMetrics? metric in metrics.Values)
{
MetricsStatistics stats = metric.GetStatistics();
totalOperations += stats.TotalCount;
averageSuccessRate += stats.SuccessRate;
}
if (metrics.Count > 0)
{
averageSuccessRate /= metrics.Count;
}
data["total_operations"] = totalOperations;
data["average_success_rate"] = averageSuccessRate;
// Determine health status
if (!isConnected)
{
return Task.FromResult(HealthCheckResult.Unhealthy(
"SCADA client is not connected",
data: data));
}
if (averageSuccessRate < 0.5 && totalOperations > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"Low success rate: {averageSuccessRate:P}",
data: data));
}
if (subscriptionStats.TotalClients > 100)
{
return Task.FromResult(HealthCheckResult.Degraded(
$"High client count: {subscriptionStats.TotalClients}",
data: data));
}
return Task.FromResult(HealthCheckResult.Healthy(
"LmxProxy is healthy",
data));
}
catch (Exception ex)
{
Logger.Error(ex, "Health check failed");
data["error"] = ex.Message;
return Task.FromResult(HealthCheckResult.Unhealthy(
"Health check threw an exception",
ex,
data));
}
}
}
/// <summary>
/// Detailed health check that performs additional connectivity tests
/// </summary>
public class DetailedHealthCheckService : IHealthCheck
{
private static readonly ILogger Logger = Log.ForContext<DetailedHealthCheckService>();
private readonly IScadaClient _scadaClient;
private readonly string _testTagAddress;
public DetailedHealthCheckService(IScadaClient scadaClient, string testTagAddress = "System.Heartbeat")
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_testTagAddress = testTagAddress;
}
public async Task<HealthCheckResult> CheckHealthAsync(
HealthCheckContext context,
CancellationToken cancellationToken = default)
{
var data = new Dictionary<string, object>();
try
{
// Basic connectivity check
if (!_scadaClient.IsConnected)
{
data["connected"] = false;
return HealthCheckResult.Unhealthy("SCADA client is not connected", data: data);
}
data["connected"] = true;
// Try to read a test tag
try
{
Vtq vtq = await _scadaClient.ReadAsync(_testTagAddress, cancellationToken);
data["test_tag_quality"] = vtq.Quality.ToString();
data["test_tag_timestamp"] = vtq.Timestamp;
if (vtq.Quality != Quality.Good)
{
return HealthCheckResult.Degraded(
$"Test tag quality is {vtq.Quality}",
data: data);
}
// Check if timestamp is recent (within last 5 minutes)
TimeSpan age = DateTime.UtcNow - vtq.Timestamp;
if (age > TimeSpan.FromMinutes(5))
{
data["timestamp_age_minutes"] = age.TotalMinutes;
return HealthCheckResult.Degraded(
$"Test tag timestamp is stale ({age.TotalMinutes:F1} minutes old)",
data: data);
}
}
catch (Exception readEx)
{
data["test_tag_error"] = readEx.Message;
return HealthCheckResult.Degraded(
"Could not read test tag",
data: data);
}
return HealthCheckResult.Healthy("All checks passed", data);
}
catch (Exception ex)
{
Logger.Error(ex, "Detailed health check failed");
data["error"] = ex.Message;
return HealthCheckResult.Unhealthy(
"Health check threw an exception",
ex,
data);
}
}
}
}

View File

@@ -0,0 +1,213 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Provides performance metrics tracking for LmxProxy operations
/// </summary>
public class PerformanceMetrics : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics = new();
private readonly Timer _reportingTimer;
private bool _disposed;
/// <summary>
/// Initializes a new instance of the PerformanceMetrics class
/// </summary>
public PerformanceMetrics()
{
// Report metrics every minute
_reportingTimer = new Timer(ReportMetrics, null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_reportingTimer?.Dispose();
ReportMetrics(null); // Final report
}
/// <summary>
/// Records the execution time of an operation
/// </summary>
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
{
OperationMetrics? metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
metrics.Record(duration, success);
}
/// <summary>
/// Creates a timing scope for measuring operation duration
/// </summary>
public ITimingScope BeginOperation(string operationName) => new TimingScope(this, operationName);
/// <summary>
/// Gets current metrics for a specific operation
/// </summary>
public OperationMetrics? GetMetrics(string operationName) =>
_metrics.TryGetValue(operationName, out OperationMetrics? metrics) ? metrics : null;
/// <summary>
/// Gets all current metrics
/// </summary>
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics() =>
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
/// <summary>
/// Gets statistics for all operations
/// </summary>
public Dictionary<string, MetricsStatistics> GetStatistics() =>
_metrics.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.GetStatistics());
private void ReportMetrics(object? state)
{
foreach (KeyValuePair<string, OperationMetrics> kvp in _metrics)
{
MetricsStatistics stats = kvp.Value.GetStatistics();
if (stats.TotalCount > 0)
{
Logger.Information(
"Performance Metrics - {Operation}: Count={Count}, Success={SuccessRate:P}, " +
"Avg={AverageMs:F2}ms, Min={MinMs:F2}ms, Max={MaxMs:F2}ms, P95={P95Ms:F2}ms",
kvp.Key,
stats.TotalCount,
stats.SuccessRate,
stats.AverageMilliseconds,
stats.MinMilliseconds,
stats.MaxMilliseconds,
stats.Percentile95Milliseconds);
}
}
}
/// <summary>
/// Timing scope for automatic duration measurement
/// </summary>
public interface ITimingScope : IDisposable
{
void SetSuccess(bool success);
}
private class TimingScope : ITimingScope
{
private readonly PerformanceMetrics _metrics;
private readonly string _operationName;
private readonly Stopwatch _stopwatch;
private bool _disposed;
private bool _success = true;
public TimingScope(PerformanceMetrics metrics, string operationName)
{
_metrics = metrics;
_operationName = operationName;
_stopwatch = Stopwatch.StartNew();
}
public void SetSuccess(bool success) => _success = success;
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_stopwatch.Stop();
_metrics.RecordOperation(_operationName, _stopwatch.Elapsed, _success);
}
}
}
/// <summary>
/// Metrics for a specific operation
/// </summary>
public class OperationMetrics
{
private readonly List<double> _durations = new();
private readonly object _lock = new();
private double _maxMilliseconds;
private double _minMilliseconds = double.MaxValue;
private long _successCount;
private long _totalCount;
private double _totalMilliseconds;
public void Record(TimeSpan duration, bool success)
{
lock (_lock)
{
double ms = duration.TotalMilliseconds;
_durations.Add(ms);
_totalCount++;
if (success)
{
_successCount++;
}
_totalMilliseconds += ms;
_minMilliseconds = Math.Min(_minMilliseconds, ms);
_maxMilliseconds = Math.Max(_maxMilliseconds, ms);
// Keep only last 1000 samples for percentile calculation
if (_durations.Count > 1000)
{
_durations.RemoveAt(0);
}
}
}
public MetricsStatistics GetStatistics()
{
lock (_lock)
{
if (_totalCount == 0)
{
return new MetricsStatistics();
}
var sortedDurations = _durations.OrderBy(d => d).ToList();
int p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1;
return new MetricsStatistics
{
TotalCount = _totalCount,
SuccessCount = _successCount,
SuccessRate = _successCount / (double)_totalCount,
AverageMilliseconds = _totalMilliseconds / _totalCount,
MinMilliseconds = _minMilliseconds == double.MaxValue ? 0 : _minMilliseconds,
MaxMilliseconds = _maxMilliseconds,
Percentile95Milliseconds = sortedDurations.Count > 0 ? sortedDurations[Math.Max(0, p95Index)] : 0
};
}
}
}
/// <summary>
/// Statistics for an operation
/// </summary>
public class MetricsStatistics
{
public long TotalCount { get; set; }
public long SuccessCount { get; set; }
public double SuccessRate { get; set; }
public double AverageMilliseconds { get; set; }
public double MinMilliseconds { get; set; }
public double MaxMilliseconds { get; set; }
public double Percentile95Milliseconds { get; set; }
}
}

View File

@@ -0,0 +1,193 @@
using System;
using System.Threading.Tasks;
using Polly;
using Polly.Timeout;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Provides retry policies for resilient operations
/// </summary>
public static class RetryPolicies
{
private static readonly ILogger Logger = Log.ForContext(typeof(RetryPolicies));
/// <summary>
/// Creates a retry policy with exponential backoff for read operations
/// </summary>
public static IAsyncPolicy<T> CreateReadPolicy<T>()
{
return Policy<T>
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt - 1)),
(outcome, timespan, retryCount, context) =>
{
Exception? exception = outcome.Exception;
Logger.Warning(exception,
"Read operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
retryCount,
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
});
}
/// <summary>
/// Creates a retry policy with exponential backoff for write operations
/// </summary>
public static IAsyncPolicy CreateWritePolicy()
{
return Policy
.Handle<Exception>(ex => !(ex is ArgumentException || ex is InvalidOperationException))
.WaitAndRetryAsync(
3,
retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt)),
(exception, timespan, retryCount, context) =>
{
Logger.Warning(exception,
"Write operation retry {RetryCount} after {DelayMs}ms. Operation: {Operation}",
retryCount,
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
});
}
/// <summary>
/// Creates a retry policy for connection operations with longer delays
/// </summary>
public static IAsyncPolicy CreateConnectionPolicy()
{
return Policy
.Handle<Exception>()
.WaitAndRetryAsync(
5,
retryAttempt =>
{
// 2s, 4s, 8s, 16s, 32s
var delay = TimeSpan.FromSeconds(Math.Min(32, Math.Pow(2, retryAttempt)));
return delay;
},
(exception, timespan, retryCount, context) =>
{
Logger.Warning(exception,
"Connection retry {RetryCount} after {DelayMs}ms",
retryCount,
timespan.TotalMilliseconds);
});
}
/// <summary>
/// Creates a circuit breaker policy for protecting against repeated failures
/// </summary>
public static IAsyncPolicy<T> CreateCircuitBreakerPolicy<T>()
{
return Policy<T>
.Handle<Exception>()
.CircuitBreakerAsync(
5,
TimeSpan.FromSeconds(30),
(result, timespan) =>
{
Logger.Error(result.Exception,
"Circuit breaker opened for {BreakDurationSeconds}s due to repeated failures",
timespan.TotalSeconds);
},
() => { Logger.Information("Circuit breaker reset - resuming normal operations"); },
() => { Logger.Information("Circuit breaker half-open - testing operation"); });
}
/// <summary>
/// Creates a combined policy with retry and circuit breaker
/// </summary>
public static IAsyncPolicy<T> CreateCombinedPolicy<T>()
{
IAsyncPolicy<T> retry = CreateReadPolicy<T>();
IAsyncPolicy<T> circuitBreaker = CreateCircuitBreakerPolicy<T>();
// Wrap retry around circuit breaker
// This means retry happens first, and if all retries fail, it counts toward the circuit breaker
return Policy.WrapAsync(retry, circuitBreaker);
}
/// <summary>
/// Creates a timeout policy for operations
/// </summary>
public static IAsyncPolicy CreateTimeoutPolicy(TimeSpan timeout)
{
return Policy
.TimeoutAsync(
timeout,
TimeoutStrategy.Pessimistic,
async (context, timespan, task) =>
{
Logger.Warning(
"Operation timed out after {TimeoutMs}ms. Operation: {Operation}",
timespan.TotalMilliseconds,
context.ContainsKey("Operation") ? context["Operation"] : "Unknown");
if (task != null)
{
try
{
await task;
}
catch
{
// Ignore exceptions from the timed-out task
}
}
});
}
/// <summary>
/// Creates a bulkhead policy to limit concurrent operations
/// </summary>
public static IAsyncPolicy CreateBulkheadPolicy(int maxParallelization, int maxQueuingActions = 100)
{
return Policy
.BulkheadAsync(
maxParallelization,
maxQueuingActions,
context =>
{
Logger.Warning(
"Bulkhead rejected operation. Max parallelization: {MaxParallel}, Queue: {MaxQueue}",
maxParallelization,
maxQueuingActions);
return Task.CompletedTask;
});
}
}
/// <summary>
/// Extension methods for applying retry policies
/// </summary>
public static class RetryPolicyExtensions
{
/// <summary>
/// Executes an operation with retry policy
/// </summary>
public static async Task<T> ExecuteWithRetryAsync<T>(
this IAsyncPolicy<T> policy,
Func<Task<T>> operation,
string operationName)
{
var context = new Context { ["Operation"] = operationName };
return await policy.ExecuteAsync(async ctx => await operation(), context);
}
/// <summary>
/// Executes an operation with retry policy (non-generic)
/// </summary>
public static async Task ExecuteWithRetryAsync(
this IAsyncPolicy policy,
Func<Task> operation,
string operationName)
{
var context = new Context { ["Operation"] = operationName };
await policy.ExecuteAsync(async ctx => await operation(), context);
}
}
}

View File

@@ -0,0 +1,182 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using Serilog;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Manages client sessions for the gRPC service.
/// Tracks active sessions with unique session IDs.
/// </summary>
public class SessionManager : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<SessionManager>();
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private bool _disposed;
/// <summary>
/// Gets the number of active sessions.
/// </summary>
public int ActiveSessionCount => _sessions.Count;
/// <summary>
/// Creates a new session for a client.
/// </summary>
/// <param name="clientId">The client identifier.</param>
/// <param name="apiKey">The API key used for authentication (optional).</param>
/// <returns>The session ID for the new session.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
public string CreateSession(string clientId, string apiKey = null)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SessionManager));
}
var sessionId = Guid.NewGuid().ToString("N");
var sessionInfo = new SessionInfo
{
SessionId = sessionId,
ClientId = clientId ?? string.Empty,
ApiKey = apiKey ?? string.Empty,
ConnectedAt = DateTime.UtcNow,
LastActivity = DateTime.UtcNow
};
_sessions[sessionId] = sessionInfo;
Logger.Information("Created session {SessionId} for client {ClientId}", sessionId, clientId);
return sessionId;
}
/// <summary>
/// Validates a session ID and updates the last activity timestamp.
/// </summary>
/// <param name="sessionId">The session ID to validate.</param>
/// <returns>True if the session is valid; otherwise, false.</returns>
public bool ValidateSession(string sessionId)
{
if (_disposed)
{
return false;
}
if (string.IsNullOrEmpty(sessionId))
{
return false;
}
if (_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo))
{
sessionInfo.LastActivity = DateTime.UtcNow;
return true;
}
return false;
}
/// <summary>
/// Gets the session information for a session ID.
/// </summary>
/// <param name="sessionId">The session ID.</param>
/// <returns>The session information, or null if not found.</returns>
public SessionInfo GetSession(string sessionId)
{
if (_disposed || string.IsNullOrEmpty(sessionId))
{
return null;
}
_sessions.TryGetValue(sessionId, out SessionInfo sessionInfo);
return sessionInfo;
}
/// <summary>
/// Terminates a session.
/// </summary>
/// <param name="sessionId">The session ID to terminate.</param>
/// <returns>True if the session was terminated; otherwise, false.</returns>
public bool TerminateSession(string sessionId)
{
if (_disposed || string.IsNullOrEmpty(sessionId))
{
return false;
}
if (_sessions.TryRemove(sessionId, out SessionInfo sessionInfo))
{
Logger.Information("Terminated session {SessionId} for client {ClientId}", sessionId, sessionInfo.ClientId);
return true;
}
return false;
}
/// <summary>
/// Gets all active sessions.
/// </summary>
/// <returns>A list of all active session information.</returns>
public IReadOnlyList<SessionInfo> GetAllSessions()
{
return _sessions.Values.ToList();
}
/// <summary>
/// Disposes the session manager and clears all sessions.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
var count = _sessions.Count;
_sessions.Clear();
Logger.Information("SessionManager disposed, cleared {Count} sessions", count);
}
}
/// <summary>
/// Contains information about a client session.
/// </summary>
public class SessionInfo
{
/// <summary>
/// Gets or sets the unique session identifier.
/// </summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the API key used for this session.
/// </summary>
public string ApiKey { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the time when the session was created.
/// </summary>
public DateTime ConnectedAt { get; set; }
/// <summary>
/// Gets or sets the time of the last activity on this session.
/// </summary>
public DateTime LastActivity { get; set; }
/// <summary>
/// Gets the connected time as UTC ticks for the gRPC response.
/// </summary>
public long ConnectedSinceUtcTicks => ConnectedAt.Ticks;
}
}

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.Diagnostics.HealthChecks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Service for collecting and formatting status information from various LmxProxy components
/// </summary>
public class StatusReportService
{
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
private readonly DetailedHealthCheckService? _detailedHealthCheckService;
private readonly HealthCheckService _healthCheckService;
private readonly PerformanceMetrics _performanceMetrics;
private readonly IScadaClient _scadaClient;
private readonly SubscriptionManager _subscriptionManager;
/// <summary>
/// Initializes a new instance of the StatusReportService class
/// </summary>
public StatusReportService(
IScadaClient scadaClient,
SubscriptionManager subscriptionManager,
PerformanceMetrics performanceMetrics,
HealthCheckService healthCheckService,
DetailedHealthCheckService? detailedHealthCheckService = null)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
_subscriptionManager = subscriptionManager ?? throw new ArgumentNullException(nameof(subscriptionManager));
_performanceMetrics = performanceMetrics ?? throw new ArgumentNullException(nameof(performanceMetrics));
_healthCheckService = healthCheckService ?? throw new ArgumentNullException(nameof(healthCheckService));
_detailedHealthCheckService = detailedHealthCheckService;
}
/// <summary>
/// Generates a comprehensive status report as HTML
/// </summary>
public async Task<string> GenerateHtmlReportAsync()
{
try
{
StatusData statusData = await CollectStatusDataAsync();
return GenerateHtmlFromStatusData(statusData);
}
catch (Exception ex)
{
Logger.Error(ex, "Error generating HTML status report");
return GenerateErrorHtml(ex);
}
}
/// <summary>
/// Generates a comprehensive status report as JSON
/// </summary>
public async Task<string> GenerateJsonReportAsync()
{
try
{
StatusData statusData = await CollectStatusDataAsync();
return JsonSerializer.Serialize(statusData, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
catch (Exception ex)
{
Logger.Error(ex, "Error generating JSON status report");
return JsonSerializer.Serialize(new { error = ex.Message }, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
}
}
/// <summary>
/// Checks if the service is healthy
/// </summary>
public async Task<bool> IsHealthyAsync()
{
try
{
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
return healthResult.Status == HealthStatus.Healthy;
}
catch (Exception ex)
{
Logger.Error(ex, "Error checking health status");
return false;
}
}
/// <summary>
/// Collects status data from all components
/// </summary>
private async Task<StatusData> CollectStatusDataAsync()
{
var statusData = new StatusData
{
Timestamp = DateTime.UtcNow,
ServiceName = "ZB.MOM.WW.LmxProxy.Host",
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "Unknown"
};
// Collect connection status
statusData.Connection = new ConnectionStatus
{
IsConnected = _scadaClient.IsConnected,
State = _scadaClient.ConnectionState.ToString(),
NodeName = "N/A", // Could be extracted from configuration if needed
GalaxyName = "N/A" // Could be extracted from configuration if needed
};
// Collect subscription statistics
SubscriptionStats subscriptionStats = _subscriptionManager.GetSubscriptionStats();
statusData.Subscriptions = new SubscriptionStatus
{
TotalClients = subscriptionStats.TotalClients,
TotalTags = subscriptionStats.TotalTags,
ActiveSubscriptions = subscriptionStats.TotalTags // Assuming same for simplicity
};
// Collect performance metrics
Dictionary<string, MetricsStatistics> perfMetrics = _performanceMetrics.GetStatistics();
statusData.Performance = new PerformanceStatus
{
TotalOperations = perfMetrics.Values.Sum(m => m.TotalCount),
AverageSuccessRate = perfMetrics.Count > 0 ? perfMetrics.Values.Average(m => m.SuccessRate) : 1.0,
Operations = perfMetrics.ToDictionary(
kvp => kvp.Key,
kvp => new OperationStatus
{
TotalCount = kvp.Value.TotalCount,
SuccessRate = kvp.Value.SuccessRate,
AverageMilliseconds = kvp.Value.AverageMilliseconds,
MinMilliseconds = kvp.Value.MinMilliseconds,
MaxMilliseconds = kvp.Value.MaxMilliseconds
})
};
// Collect health check results
try
{
HealthCheckResult healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.Health = new HealthInfo
{
Status = healthResult.Status.ToString(),
Description = healthResult.Description ?? "",
Data = healthResult.Data?.ToDictionary(kvp => kvp.Key, kvp => kvp.Value?.ToString() ?? "") ??
new Dictionary<string, string>()
};
// Collect detailed health check if available
if (_detailedHealthCheckService != null)
{
HealthCheckResult detailedHealthResult =
await _detailedHealthCheckService.CheckHealthAsync(new HealthCheckContext());
statusData.DetailedHealth = new HealthInfo
{
Status = detailedHealthResult.Status.ToString(),
Description = detailedHealthResult.Description ?? "",
Data = detailedHealthResult.Data?.ToDictionary(kvp => kvp.Key,
kvp => kvp.Value?.ToString() ?? "") ?? new Dictionary<string, string>()
};
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error collecting health check data");
statusData.Health = new HealthInfo
{
Status = "Error",
Description = $"Health check failed: {ex.Message}",
Data = new Dictionary<string, string>()
};
}
return statusData;
}
/// <summary>
/// Generates HTML from status data
/// </summary>
private static string GenerateHtmlFromStatusData(StatusData statusData)
{
var html = new StringBuilder();
html.AppendLine("<!DOCTYPE html>");
html.AppendLine("<html>");
html.AppendLine("<head>");
html.AppendLine(" <title>LmxProxy Status</title>");
html.AppendLine(" <meta charset=\"utf-8\">");
html.AppendLine(" <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">");
html.AppendLine(" <meta http-equiv=\"refresh\" content=\"30\">");
html.AppendLine(" <style>");
html.AppendLine(
" body { font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }");
html.AppendLine(
" .container { max-width: 1200px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }");
html.AppendLine(" .header { text-align: center; margin-bottom: 30px; }");
html.AppendLine(
" .status-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 20px; }");
html.AppendLine(
" .status-card { background: #f9f9f9; padding: 15px; border-radius: 6px; border-left: 4px solid #007acc; }");
html.AppendLine(" .status-card h3 { margin-top: 0; color: #333; }");
html.AppendLine(" .status-value { font-weight: bold; color: #007acc; }");
html.AppendLine(" .status-healthy { color: #28a745; }");
html.AppendLine(" .status-warning { color: #ffc107; }");
html.AppendLine(" .status-error { color: #dc3545; }");
html.AppendLine(" .status-connected { border-left-color: #28a745; }");
html.AppendLine(" .status-disconnected { border-left-color: #dc3545; }");
html.AppendLine(" table { width: 100%; border-collapse: collapse; margin-top: 10px; }");
html.AppendLine(" th, td { text-align: left; padding: 8px; border-bottom: 1px solid #ddd; }");
html.AppendLine(" th { background-color: #f2f2f2; }");
html.AppendLine(
" .timestamp { text-align: center; margin-top: 20px; color: #666; font-size: 0.9em; }");
html.AppendLine(" </style>");
html.AppendLine("</head>");
html.AppendLine("<body>");
html.AppendLine(" <div class=\"container\">");
// Header
html.AppendLine(" <div class=\"header\">");
html.AppendLine(" <h1>LmxProxy Status Dashboard</h1>");
html.AppendLine($" <p>Service: {statusData.ServiceName} | Version: {statusData.Version}</p>");
html.AppendLine(" </div>");
html.AppendLine(" <div class=\"status-grid\">");
// Connection Status Card
string connectionClass = statusData.Connection.IsConnected ? "status-connected" : "status-disconnected";
string connectionStatusText = statusData.Connection.IsConnected ? "Connected" : "Disconnected";
string connectionStatusClass = statusData.Connection.IsConnected ? "status-healthy" : "status-error";
html.AppendLine($" <div class=\"status-card {connectionClass}\">");
html.AppendLine(" <h3>MxAccess Connection</h3>");
html.AppendLine(
$" <p>Status: <span class=\"status-value {connectionStatusClass}\">{connectionStatusText}</span></p>");
html.AppendLine(
$" <p>State: <span class=\"status-value\">{statusData.Connection.State}</span></p>");
html.AppendLine(" </div>");
// Subscription Status Card
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Subscriptions</h3>");
html.AppendLine(
$" <p>Total Clients: <span class=\"status-value\">{statusData.Subscriptions.TotalClients}</span></p>");
html.AppendLine(
$" <p>Total Tags: <span class=\"status-value\">{statusData.Subscriptions.TotalTags}</span></p>");
html.AppendLine(
$" <p>Active Subscriptions: <span class=\"status-value\">{statusData.Subscriptions.ActiveSubscriptions}</span></p>");
html.AppendLine(" </div>");
// Performance Status Card
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Performance</h3>");
html.AppendLine(
$" <p>Total Operations: <span class=\"status-value\">{statusData.Performance.TotalOperations:N0}</span></p>");
html.AppendLine(
$" <p>Success Rate: <span class=\"status-value\">{statusData.Performance.AverageSuccessRate:P2}</span></p>");
html.AppendLine(" </div>");
// Health Status Card
string healthStatusClass = statusData.Health.Status.ToLowerInvariant() switch
{
"healthy" => "status-healthy",
"degraded" => "status-warning",
_ => "status-error"
};
html.AppendLine(" <div class=\"status-card\">");
html.AppendLine(" <h3>Health Status</h3>");
html.AppendLine(
$" <p>Status: <span class=\"status-value {healthStatusClass}\">{statusData.Health.Status}</span></p>");
html.AppendLine(
$" <p>Description: <span class=\"status-value\">{statusData.Health.Description}</span></p>");
html.AppendLine(" </div>");
html.AppendLine(" </div>");
// Performance Metrics Table
if (statusData.Performance.Operations.Any())
{
html.AppendLine(" <div class=\"status-card\" style=\"margin-top: 20px;\">");
html.AppendLine(" <h3>Operation Performance Metrics</h3>");
html.AppendLine(" <table>");
html.AppendLine(" <tr>");
html.AppendLine(" <th>Operation</th>");
html.AppendLine(" <th>Count</th>");
html.AppendLine(" <th>Success Rate</th>");
html.AppendLine(" <th>Avg (ms)</th>");
html.AppendLine(" <th>Min (ms)</th>");
html.AppendLine(" <th>Max (ms)</th>");
html.AppendLine(" </tr>");
foreach (KeyValuePair<string, OperationStatus> operation in statusData.Performance.Operations)
{
html.AppendLine(" <tr>");
html.AppendLine($" <td>{operation.Key}</td>");
html.AppendLine($" <td>{operation.Value.TotalCount:N0}</td>");
html.AppendLine($" <td>{operation.Value.SuccessRate:P2}</td>");
html.AppendLine($" <td>{operation.Value.AverageMilliseconds:F2}</td>");
html.AppendLine($" <td>{operation.Value.MinMilliseconds:F2}</td>");
html.AppendLine($" <td>{operation.Value.MaxMilliseconds:F2}</td>");
html.AppendLine(" </tr>");
}
html.AppendLine(" </table>");
html.AppendLine(" </div>");
}
// Timestamp
html.AppendLine(
$" <div class=\"timestamp\">Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC</div>");
html.AppendLine(" </div>");
html.AppendLine("</body>");
html.AppendLine("</html>");
return html.ToString();
}
/// <summary>
/// Generates error HTML when status collection fails
/// </summary>
private static string GenerateErrorHtml(Exception ex)
{
return $@"<!DOCTYPE html>
<html>
<head>
<title>LmxProxy Status - Error</title>
<meta charset=""utf-8"">
<style>
body {{ font-family: Arial, sans-serif; margin: 40px; background-color: #f5f5f5; }}
.container {{ max-width: 800px; margin: 0 auto; background-color: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }}
.error {{ color: #dc3545; background-color: #f8d7da; padding: 15px; border-radius: 6px; border: 1px solid #f5c6cb; }}
</style>
</head>
<body>
<div class=""container"">
<h1>LmxProxy Status Dashboard</h1>
<div class=""error"">
<h3>Error Loading Status</h3>
<p>An error occurred while collecting status information:</p>
<p><strong>{ex.Message}</strong></p>
</div>
<div style=""text-align: center; margin-top: 20px; color: #666; font-size: 0.9em;"">
Last updated: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC
</div>
</div>
</body>
</html>";
}
}
/// <summary>
/// Data structure for holding complete status information
/// </summary>
public class StatusData
{
public DateTime Timestamp { get; set; }
public string ServiceName { get; set; } = "";
public string Version { get; set; } = "";
public ConnectionStatus Connection { get; set; } = new();
public SubscriptionStatus Subscriptions { get; set; } = new();
public PerformanceStatus Performance { get; set; } = new();
public HealthInfo Health { get; set; } = new();
public HealthInfo? DetailedHealth { get; set; }
}
/// <summary>
/// Connection status information
/// </summary>
public class ConnectionStatus
{
public bool IsConnected { get; set; }
public string State { get; set; } = "";
public string NodeName { get; set; } = "";
public string GalaxyName { get; set; } = "";
}
/// <summary>
/// Subscription status information
/// </summary>
public class SubscriptionStatus
{
public int TotalClients { get; set; }
public int TotalTags { get; set; }
public int ActiveSubscriptions { get; set; }
}
/// <summary>
/// Performance status information
/// </summary>
public class PerformanceStatus
{
public long TotalOperations { get; set; }
public double AverageSuccessRate { get; set; }
public Dictionary<string, OperationStatus> Operations { get; set; } = new();
}
/// <summary>
/// Individual operation status
/// </summary>
public class OperationStatus
{
public long TotalCount { get; set; }
public double SuccessRate { get; set; }
public double AverageMilliseconds { get; set; }
public double MinMilliseconds { get; set; }
public double MaxMilliseconds { get; set; }
}
/// <summary>
/// Health check status information
/// </summary>
public class HealthInfo
{
public string Status { get; set; } = "";
public string Description { get; set; } = "";
public Dictionary<string, string> Data { get; set; } = new();
}
}

View File

@@ -0,0 +1,315 @@
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// HTTP web server that serves status information for the LmxProxy service
/// </summary>
public class StatusWebServer : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
private readonly WebServerConfiguration _configuration;
private readonly StatusReportService _statusReportService;
private CancellationTokenSource? _cancellationTokenSource;
private bool _disposed;
private HttpListener? _httpListener;
private Task? _listenerTask;
/// <summary>
/// Initializes a new instance of the StatusWebServer class
/// </summary>
/// <param name="configuration">Web server configuration</param>
/// <param name="statusReportService">Service for collecting status information</param>
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService)
{
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
_statusReportService = statusReportService ?? throw new ArgumentNullException(nameof(statusReportService));
}
/// <summary>
/// Disposes the web server and releases resources
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Stop();
_cancellationTokenSource?.Dispose();
_httpListener?.Close();
}
/// <summary>
/// Starts the HTTP web server
/// </summary>
/// <returns>True if started successfully, false otherwise</returns>
public bool Start()
{
try
{
if (!_configuration.Enabled)
{
Logger.Information("Status web server is disabled");
return true;
}
Logger.Information("Starting status web server on port {Port}", _configuration.Port);
_httpListener = new HttpListener();
// Configure the URL prefix
string prefix = _configuration.Prefix ?? $"http://+:{_configuration.Port}/";
if (!prefix.EndsWith("/"))
{
prefix += "/";
}
_httpListener.Prefixes.Add(prefix);
_httpListener.Start();
_cancellationTokenSource = new CancellationTokenSource();
_listenerTask = Task.Run(() => HandleRequestsAsync(_cancellationTokenSource.Token));
Logger.Information("Status web server started successfully on {Prefix}", prefix);
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to start status web server");
return false;
}
}
/// <summary>
/// Stops the HTTP web server
/// </summary>
/// <returns>True if stopped successfully, false otherwise</returns>
public bool Stop()
{
try
{
if (!_configuration.Enabled || _httpListener == null)
{
return true;
}
Logger.Information("Stopping status web server");
_cancellationTokenSource?.Cancel();
if (_listenerTask != null)
{
try
{
_listenerTask.Wait(TimeSpan.FromSeconds(5));
}
catch (Exception ex)
{
Logger.Warning(ex, "Error waiting for listener task to complete");
}
}
_httpListener?.Stop();
_httpListener?.Close();
Logger.Information("Status web server stopped successfully");
return true;
}
catch (Exception ex)
{
Logger.Error(ex, "Error stopping status web server");
return false;
}
}
/// <summary>
/// Main request handling loop
/// </summary>
private async Task HandleRequestsAsync(CancellationToken cancellationToken)
{
Logger.Information("Status web server listener started");
while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening)
{
try
{
HttpListenerContext? context = await _httpListener.GetContextAsync();
// Handle request asynchronously without waiting
_ = Task.Run(async () =>
{
try
{
await HandleRequestAsync(context);
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request from {RemoteEndPoint}",
context.Request.RemoteEndPoint);
}
}, cancellationToken);
}
catch (ObjectDisposedException)
{
// Expected when stopping the listener
break;
}
catch (HttpListenerException ex) when (ex.ErrorCode == 995) // ERROR_OPERATION_ABORTED
{
// Expected when stopping the listener
break;
}
catch (Exception ex)
{
Logger.Error(ex, "Error in request listener loop");
// Brief delay before continuing to avoid tight error loops
try
{
await Task.Delay(1000, cancellationToken);
}
catch (OperationCanceledException)
{
break;
}
}
}
Logger.Information("Status web server listener stopped");
}
/// <summary>
/// Handles a single HTTP request
/// </summary>
private async Task HandleRequestAsync(HttpListenerContext context)
{
HttpListenerRequest? request = context.Request;
HttpListenerResponse response = context.Response;
try
{
Logger.Debug("Handling {Method} request to {Url} from {RemoteEndPoint}",
request.HttpMethod, request.Url?.AbsolutePath, request.RemoteEndPoint);
// Only allow GET requests
if (request.HttpMethod != "GET")
{
response.StatusCode = 405; // Method Not Allowed
response.StatusDescription = "Method Not Allowed";
await WriteResponseAsync(response, "Only GET requests are supported", "text/plain");
return;
}
string path = request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/";
switch (path)
{
case "/":
await HandleStatusPageAsync(response);
break;
case "/api/status":
await HandleStatusApiAsync(response);
break;
case "/api/health":
await HandleHealthApiAsync(response);
break;
default:
response.StatusCode = 404; // Not Found
response.StatusDescription = "Not Found";
await WriteResponseAsync(response, "Resource not found", "text/plain");
break;
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error handling HTTP request");
try
{
response.StatusCode = 500; // Internal Server Error
response.StatusDescription = "Internal Server Error";
await WriteResponseAsync(response, "Internal server error", "text/plain");
}
catch (Exception responseEx)
{
Logger.Error(responseEx, "Error writing error response");
}
}
finally
{
try
{
response.Close();
}
catch (Exception ex)
{
Logger.Warning(ex, "Error closing HTTP response");
}
}
}
/// <summary>
/// Handles the main status page (HTML)
/// </summary>
private async Task HandleStatusPageAsync(HttpListenerResponse response)
{
string statusHtml = await _statusReportService.GenerateHtmlReportAsync();
await WriteResponseAsync(response, statusHtml, "text/html; charset=utf-8");
}
/// <summary>
/// Handles the status API endpoint (JSON)
/// </summary>
private async Task HandleStatusApiAsync(HttpListenerResponse response)
{
string statusJson = await _statusReportService.GenerateJsonReportAsync();
await WriteResponseAsync(response, statusJson, "application/json; charset=utf-8");
}
/// <summary>
/// Handles the health API endpoint (simple text)
/// </summary>
private async Task HandleHealthApiAsync(HttpListenerResponse response)
{
bool isHealthy = await _statusReportService.IsHealthyAsync();
string healthText = isHealthy ? "OK" : "UNHEALTHY";
response.StatusCode = isHealthy ? 200 : 503; // Service Unavailable if unhealthy
await WriteResponseAsync(response, healthText, "text/plain");
}
/// <summary>
/// Writes a response to the HTTP context
/// </summary>
private static async Task WriteResponseAsync(HttpListenerResponse response, string content, string contentType)
{
response.ContentType = contentType;
response.Headers.Add("Cache-Control", "no-cache, no-store, must-revalidate");
response.Headers.Add("Pragma", "no-cache");
response.Headers.Add("Expires", "0");
byte[] buffer = Encoding.UTF8.GetBytes(content);
response.ContentLength64 = buffer.Length;
using (Stream? output = response.OutputStream)
{
await output.WriteAsync(buffer, 0, buffer.Length);
}
}
}
}

View File

@@ -0,0 +1,535 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Serilog;
using ZB.MOM.WW.LmxProxy.Host.Configuration;
using ZB.MOM.WW.LmxProxy.Host.Domain;
namespace ZB.MOM.WW.LmxProxy.Host.Services
{
/// <summary>
/// Manages subscriptions for multiple gRPC clients, handling tag subscriptions, message delivery, and client
/// statistics.
/// </summary>
public class SubscriptionManager : IDisposable
{
private static readonly ILogger Logger = Log.ForContext<SubscriptionManager>();
// Configuration for channel buffering
private readonly int _channelCapacity;
private readonly BoundedChannelFullMode _channelFullMode;
private readonly ConcurrentDictionary<string, ClientSubscription> _clientSubscriptions = new();
private readonly ReaderWriterLockSlim _lock = new(LockRecursionPolicy.NoRecursion);
private readonly IScadaClient _scadaClient;
private readonly ConcurrentDictionary<string, TagSubscription> _tagSubscriptions = new();
private bool _disposed;
/// <summary>
/// Initializes a new instance of the <see cref="SubscriptionManager" /> class.
/// </summary>
/// <param name="scadaClient">The SCADA client to use for subscriptions.</param>
/// <param name="configuration">The subscription configuration.</param>
/// <exception cref="ArgumentNullException">
/// Thrown if <paramref name="scadaClient" /> or <paramref name="configuration" />
/// is null.
/// </exception>
public SubscriptionManager(IScadaClient scadaClient, SubscriptionConfiguration configuration)
{
_scadaClient = scadaClient ?? throw new ArgumentNullException(nameof(scadaClient));
SubscriptionConfiguration configuration1 =
configuration ?? throw new ArgumentNullException(nameof(configuration));
_channelCapacity = configuration1.ChannelCapacity;
_channelFullMode = ParseChannelFullMode(configuration1.ChannelFullMode);
// Subscribe to connection state changes
_scadaClient.ConnectionStateChanged += OnConnectionStateChanged;
Logger.Information("SubscriptionManager initialized with channel capacity: {Capacity}, full mode: {Mode}",
_channelCapacity, _channelFullMode);
}
/// <summary>
/// Disposes the <see cref="SubscriptionManager" />, unsubscribing all clients and cleaning up resources.
/// </summary>
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
Logger.Information("Disposing SubscriptionManager");
// Unsubscribe from connection state changes
_scadaClient.ConnectionStateChanged -= OnConnectionStateChanged;
// Unsubscribe all clients
var clientIds = _clientSubscriptions.Keys.ToList();
foreach (string? clientId in clientIds)
{
UnsubscribeClient(clientId);
}
_clientSubscriptions.Clear();
_tagSubscriptions.Clear();
// Dispose the lock
_lock?.Dispose();
}
/// <summary>
/// Gets the number of active client subscriptions.
/// </summary>
public virtual int GetActiveSubscriptionCount() => _clientSubscriptions.Count;
/// <summary>
/// Parses the channel full mode string to <see cref="BoundedChannelFullMode" />.
/// </summary>
/// <param name="mode">The mode string.</param>
/// <returns>The parsed <see cref="BoundedChannelFullMode" /> value.</returns>
private static BoundedChannelFullMode ParseChannelFullMode(string mode)
{
return mode?.ToUpperInvariant() switch
{
"DROPOLDEST" => BoundedChannelFullMode.DropOldest,
"DROPNEWEST" => BoundedChannelFullMode.DropNewest,
"WAIT" => BoundedChannelFullMode.Wait,
_ => BoundedChannelFullMode.DropOldest // Default
};
}
/// <summary>
/// Creates a new subscription for a client to a set of tag addresses.
/// </summary>
/// <param name="clientId">The client identifier.</param>
/// <param name="addresses">The tag addresses to subscribe to.</param>
/// <param name="ct">Optional cancellation token.</param>
/// <returns>A channel for receiving tag updates.</returns>
/// <exception cref="ObjectDisposedException">Thrown if the manager is disposed.</exception>
public async Task<Channel<(string address, Vtq vtq)>> SubscribeAsync(
string clientId,
IEnumerable<string> addresses,
CancellationToken ct = default)
{
if (_disposed)
{
throw new ObjectDisposedException(nameof(SubscriptionManager));
}
var addressList = addresses.ToList();
Logger.Information("Client {ClientId} subscribing to {Count} tags", clientId, addressList.Count);
// Create a bounded channel for this client with buffering
var channel = Channel.CreateBounded<(string address, Vtq vtq)>(new BoundedChannelOptions(_channelCapacity)
{
FullMode = _channelFullMode,
SingleReader = true,
SingleWriter = false,
AllowSynchronousContinuations = false
});
Logger.Debug("Created bounded channel for client {ClientId} with capacity {Capacity}", clientId,
_channelCapacity);
var clientSubscription = new ClientSubscription
{
ClientId = clientId,
Channel = channel,
Addresses = new HashSet<string>(addressList),
CancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct)
};
_clientSubscriptions[clientId] = clientSubscription;
// Subscribe to each tag
foreach (string? address in addressList)
{
await SubscribeToTagAsync(address, clientId);
}
// Handle client disconnection
clientSubscription.CancellationTokenSource.Token.Register(() =>
{
Logger.Information("Client {ClientId} disconnected, cleaning up subscriptions", clientId);
UnsubscribeClient(clientId);
});
return channel;
}
/// <summary>
/// Unsubscribes a client from all tags and cleans up resources.
/// </summary>
/// <param name="clientId">The client identifier.</param>
public void UnsubscribeClient(string clientId)
{
if (_clientSubscriptions.TryRemove(clientId, out ClientSubscription? clientSubscription))
{
Logger.Information(
"Unsubscribing client {ClientId} from {Count} tags. Stats: Delivered={Delivered}, Dropped={Dropped}",
clientId, clientSubscription.Addresses.Count,
clientSubscription.DeliveredMessageCount, clientSubscription.DroppedMessageCount);
_lock.EnterWriteLock();
try
{
foreach (string? address in clientSubscription.Addresses)
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
tagSubscription.ClientIds.Remove(clientId);
// If no more clients are subscribed to this tag, unsubscribe from SCADA
if (tagSubscription.ClientIds.Count == 0)
{
Logger.Information(
"No more clients subscribed to {Address}, removing SCADA subscription", address);
_tagSubscriptions.TryRemove(address, out _);
// Dispose the SCADA subscription
Task.Run(async () =>
{
try
{
if (tagSubscription.ScadaSubscription != null)
{
await tagSubscription.ScadaSubscription.DisposeAsync();
Logger.Debug("Successfully disposed SCADA subscription for {Address}",
address);
}
}
catch (Exception ex)
{
Logger.Error(ex, "Error disposing SCADA subscription for {Address}", address);
}
});
}
else
{
Logger.Debug(
"Client {ClientId} removed from {Address} subscription (remaining clients: {Count})",
clientId, address, tagSubscription.ClientIds.Count);
}
}
}
}
finally
{
_lock.ExitWriteLock();
}
// Complete the channel
clientSubscription.Channel.Writer.TryComplete();
clientSubscription.CancellationTokenSource.Dispose();
}
}
/// <summary>
/// Subscribes a client to a tag address, creating a new SCADA subscription if needed.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="clientId">The client identifier.</param>
private async Task SubscribeToTagAsync(string address, string clientId)
{
bool needsSubscription;
TagSubscription? tagSubscription;
_lock.EnterWriteLock();
try
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? existingSubscription))
{
// Tag is already subscribed, just add this client
existingSubscription.ClientIds.Add(clientId);
Logger.Debug(
"Client {ClientId} added to existing subscription for {Address} (total clients: {Count})",
clientId, address, existingSubscription.ClientIds.Count);
return;
}
// Create new tag subscription and reserve the spot
tagSubscription = new TagSubscription
{
Address = address,
ClientIds = new HashSet<string> { clientId }
};
_tagSubscriptions[address] = tagSubscription;
needsSubscription = true;
}
finally
{
_lock.ExitWriteLock();
}
if (needsSubscription && tagSubscription != null)
{
// Subscribe to SCADA outside of lock to avoid blocking
Logger.Debug("Creating new SCADA subscription for {Address}", address);
try
{
IAsyncDisposable scadaSubscription = await _scadaClient.SubscribeAsync(
new[] { address },
(addr, vtq) => OnTagValueChanged(addr, vtq),
CancellationToken.None);
_lock.EnterWriteLock();
try
{
tagSubscription.ScadaSubscription = scadaSubscription;
}
finally
{
_lock.ExitWriteLock();
}
Logger.Information("Successfully subscribed to {Address} for client {ClientId}", address, clientId);
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to subscribe to {Address}", address);
// Remove the failed subscription
_lock.EnterWriteLock();
try
{
_tagSubscriptions.TryRemove(address, out _);
}
finally
{
_lock.ExitWriteLock();
}
throw;
}
}
}
/// <summary>
/// Handles tag value changes and delivers updates to all subscribed clients.
/// </summary>
/// <param name="address">The tag address.</param>
/// <param name="vtq">The value, timestamp, and quality.</param>
private void OnTagValueChanged(string address, Vtq vtq)
{
Logger.Debug("Tag value changed: {Address} = {Vtq}", address, vtq);
_lock.EnterReadLock();
try
{
if (!_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
Logger.Warning("Received update for untracked tag {Address}", address);
return;
}
// Send update to all subscribed clients
// Use the existing collection directly without ToList() since we're in a read lock
foreach (string? clientId in tagSubscription.ClientIds)
{
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
{
try
{
if (!clientSubscription.Channel.Writer.TryWrite((address, vtq)))
{
// Channel is full - with DropOldest mode, this should rarely happen
Logger.Warning(
"Channel full for client {ClientId}, dropping message for {Address}. Consider increasing buffer size.",
clientId, address);
clientSubscription.DroppedMessageCount++;
}
else
{
clientSubscription.DeliveredMessageCount++;
}
}
catch (InvalidOperationException ex) when (ex.Message.Contains("closed"))
{
Logger.Debug("Channel closed for client {ClientId}, removing subscription", clientId);
// Schedule cleanup of disconnected client
Task.Run(() => UnsubscribeClient(clientId));
}
catch (Exception ex)
{
Logger.Error(ex, "Error sending update to client {ClientId}", clientId);
}
}
}
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Gets current subscription statistics for all clients and tags.
/// </summary>
/// <returns>A <see cref="SubscriptionStats" /> object containing statistics.</returns>
public virtual SubscriptionStats GetSubscriptionStats()
{
_lock.EnterReadLock();
try
{
var tagClientCounts = _tagSubscriptions.ToDictionary(
kvp => kvp.Key,
kvp => kvp.Value.ClientIds.Count);
var clientStats = _clientSubscriptions.ToDictionary(
kvp => kvp.Key,
kvp => new ClientStats
{
SubscribedTags = kvp.Value.Addresses.Count,
DeliveredMessages = kvp.Value.DeliveredMessageCount,
DroppedMessages = kvp.Value.DroppedMessageCount
});
return new SubscriptionStats
{
TotalClients = _clientSubscriptions.Count,
TotalTags = _tagSubscriptions.Count,
TagClientCounts = tagClientCounts,
ClientStats = clientStats
};
}
finally
{
_lock.ExitReadLock();
}
}
/// <summary>
/// Handles SCADA client connection state changes and notifies clients of disconnection.
/// </summary>
/// <param name="sender">The event sender.</param>
/// <param name="e">The connection state change event arguments.</param>
private void OnConnectionStateChanged(object? sender, ConnectionStateChangedEventArgs e)
{
Logger.Information("Connection state changed from {Previous} to {Current}",
e.PreviousState, e.CurrentState);
// If we're disconnected, notify all subscribed clients with bad quality
if (e.CurrentState != ConnectionState.Connected)
{
Task.Run(async () =>
{
try
{
await NotifyAllClientsOfDisconnection();
}
catch (Exception ex)
{
Logger.Error(ex, "Error notifying clients of disconnection");
}
});
}
}
/// <summary>
/// Notifies all clients of a SCADA disconnection by sending bad quality updates.
/// </summary>
private async Task NotifyAllClientsOfDisconnection()
{
Logger.Information("Notifying all clients of disconnection");
var badQualityVtq = new Vtq(null, DateTime.UtcNow, Quality.Bad);
// Get all unique addresses being subscribed to
var allAddresses = _tagSubscriptions.Keys.ToList();
// Send bad quality update for each address to all subscribed clients
foreach (string? address in allAddresses)
{
if (_tagSubscriptions.TryGetValue(address, out TagSubscription? tagSubscription))
{
var clientIds = tagSubscription.ClientIds.ToList();
foreach (string? clientId in clientIds)
{
if (_clientSubscriptions.TryGetValue(clientId, out ClientSubscription? clientSubscription))
{
try
{
await clientSubscription.Channel.Writer.WriteAsync((address, badQualityVtq));
Logger.Debug("Sent bad quality notification for {Address} to client {ClientId}",
address, clientId);
}
catch (Exception ex)
{
Logger.Warning(ex, "Failed to send bad quality notification to client {ClientId}",
clientId);
}
}
}
}
}
}
/// <summary>
/// Represents a client's subscription, including channel, addresses, and statistics.
/// </summary>
private class ClientSubscription
{
/// <summary>
/// Gets or sets the client identifier.
/// </summary>
public string ClientId { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the channel for delivering tag updates.
/// </summary>
public Channel<(string address, Vtq vtq)> Channel { get; set; } = null!;
/// <summary>
/// Gets or sets the set of addresses the client is subscribed to.
/// </summary>
public HashSet<string> Addresses { get; set; } = new();
/// <summary>
/// Gets or sets the cancellation token source for the client.
/// </summary>
public CancellationTokenSource CancellationTokenSource { get; set; } = null!;
/// <summary>
/// Gets or sets the count of delivered messages.
/// </summary>
public long DeliveredMessageCount { get; set; }
/// <summary>
/// Gets or sets the count of dropped messages.
/// </summary>
public long DroppedMessageCount { get; set; }
}
/// <summary>
/// Represents a tag subscription, including address, client IDs, and SCADA subscription handle.
/// </summary>
private class TagSubscription
{
/// <summary>
/// Gets or sets the tag address.
/// </summary>
public string Address { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the set of client IDs subscribed to this tag.
/// </summary>
public HashSet<string> ClientIds { get; set; } = new();
/// <summary>
/// Gets or sets the SCADA subscription handle.
/// </summary>
public IAsyncDisposable ScadaSubscription { get; set; } = null!;
}
}
}

View File

@@ -0,0 +1,65 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<OutputType>Exe</OutputType>
<LangVersion>9.0</LangVersion>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<RootNamespace>ZB.MOM.WW.LmxProxy.Host</RootNamespace>
<AssemblyName>ZB.MOM.WW.LmxProxy.Host</AssemblyName>
<!-- Force x86 architecture for all configurations (required by ArchestrA.MXAccess) -->
<PlatformTarget>x86</PlatformTarget>
<Platforms>x86</Platforms>
<Prefer32Bit>true</Prefer32Bit>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Grpc.Core" Version="2.46.6"/>
<PackageReference Include="Grpc.Tools" Version="2.51.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Google.Protobuf" Version="3.21.12"/>
<PackageReference Include="Topshelf" Version="4.3.0"/>
<PackageReference Include="Topshelf.Serilog" Version="4.3.0"/>
<PackageReference Include="Serilog" Version="2.10.0"/>
<PackageReference Include="Serilog.Sinks.Console" Version="4.0.1"/>
<PackageReference Include="Serilog.Sinks.File" Version="5.0.0"/>
<PackageReference Include="Serilog.Settings.Configuration" Version="3.3.0"/>
<PackageReference Include="Serilog.Formatting.Compact" Version="1.1.0"/>
<PackageReference Include="System.Threading.Channels" Version="4.7.1"/>
<PackageReference Include="Microsoft.Extensions.Configuration" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="3.1.32"/>
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="3.1.32"/>
<PackageReference Include="Polly" Version="7.2.4"/>
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="3.1.32"/>
<PackageReference Include="System.Memory" Version="4.5.5"/>
<PackageReference Include="System.Runtime.CompilerServices.Unsafe" Version="4.7.1"/>
</ItemGroup>
<ItemGroup>
<Reference Include="ArchestrA.MXAccess">
<HintPath>..\..\lib\ArchestrA.MXAccess.dll</HintPath>
<Private>true</Private>
</Reference>
</ItemGroup>
<ItemGroup>
<Protobuf Include="Grpc\Protos\*.proto" GrpcServices="Both"/>
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="appsettings.*.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="App.config">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,40 @@
{
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Grpc": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"formatter": "Serilog.Formatting.Compact.CompactJsonFormatter, Serilog.Formatting.Compact"
}
},
{
"Name": "File",
"Args": {
"path": "logs/lmxproxy-.json",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"formatter": "Serilog.Formatting.Compact.RenderedCompactJsonFormatter, Serilog.Formatting.Compact"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId",
"WithProcessId",
"WithEnvironmentName"
],
"Properties": {
"Application": "LmxProxy",
"Environment": "Production"
}
}
}

View File

@@ -0,0 +1,84 @@
{
"GrpcPort": 50051,
"ApiKeyConfigFile": "apikeys.json",
"Subscription": {
"ChannelCapacity": 1000,
"ChannelFullMode": "DropOldest"
},
"ServiceRecovery": {
"FirstFailureDelayMinutes": 1,
"SecondFailureDelayMinutes": 5,
"SubsequentFailureDelayMinutes": 10,
"ResetPeriodDays": 1
},
"Connection": {
"MonitorIntervalSeconds": 5,
"ConnectionTimeoutSeconds": 30,
"AutoReconnect": true,
"ReadTimeoutSeconds": 5,
"WriteTimeoutSeconds": 5,
"MaxConcurrentOperations": 10
},
"PerformanceMetrics": {
"ReportingIntervalSeconds": 60,
"Enabled": true,
"MaxSamplesPerMetric": 1000
},
"HealthCheck": {
"Enabled": true,
"TestTagAddress": "TestChannel.TestDevice.TestTag",
"MaxStaleDataMinutes": 5
},
"RetryPolicies": {
"ReadRetryCount": 3,
"WriteRetryCount": 3,
"ConnectionRetryCount": 5,
"CircuitBreakerThreshold": 5,
"CircuitBreakerDurationSeconds": 30
},
"Tls": {
"Enabled": true,
"ServerCertificatePath": "certs/server.crt",
"ServerKeyPath": "certs/server.key",
"ClientCaCertificatePath": "certs/ca.crt",
"RequireClientCertificate": false,
"CheckCertificateRevocation": false
},
"WebServer": {
"Enabled": true,
"Port": 8080
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning",
"Grpc": "Information"
}
},
"WriteTo": [
{
"Name": "Console",
"Args": {
"theme": "Serilog.Sinks.SystemConsole.Themes.AnsiConsoleTheme::Code, Serilog.Sinks.Console",
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}"
}
},
{
"Name": "File",
"Args": {
"path": "logs/lmxproxy-.txt",
"rollingInterval": "Day",
"retainedFileCountLimit": 30,
"outputTemplate": "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {SourceContext} {Message:lj}{NewLine}{Exception}"
}
}
],
"Enrich": [
"FromLogContext",
"WithMachineName",
"WithThreadId"
]
}
}

View File

@@ -0,0 +1,52 @@
{
"GrpcPort": 50051,
"ApiKeyConfigFile": "apikeys.json",
"Connection": {
"MonitorIntervalSeconds": 5,
"ConnectionTimeoutSeconds": 30,
"AutoReconnect": true,
"ReadTimeoutSeconds": 5,
"WriteTimeoutSeconds": 5,
"MaxConcurrentOperations": 10
},
"Subscription": {
"ChannelCapacity": 10000,
"ChannelFullMode": "DropOldest"
},
"ServiceRecovery": {
"FirstFailureDelayMinutes": 1,
"SecondFailureDelayMinutes": 5,
"SubsequentFailureDelayMinutes": 10,
"ResetPeriodDays": 1
},
"Tls": {
"Enabled": true,
"ServerCertificatePath": "certs/server.crt",
"ServerKeyPath": "certs/server.key",
"ClientCaCertificatePath": "certs/ca.crt",
"RequireClientCertificate": false,
"CheckCertificateRevocation": false
},
"Serilog": {
"MinimumLevel": {
"Default": "Information",
"Override": {
"Microsoft": "Warning",
"System": "Warning"
}
},
"WriteTo": [
{
"Name": "Console"
},
{
"Name": "File",
"Args": {
"path": "logs/lmxproxy-.log",
"rollingInterval": "Day",
"retainedFileCountLimit": 7
}
}
]
}
}