feat(lmxproxy): phase 1 — v2 protocol types and domain model
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 7–6 = major (00=Bad, 01=Uncertain, 11=Good),
|
||||
/// bits 5–2 = substatus, bits 1–0 = 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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 <= 0).</summary>
|
||||
[DataMember(Order = 5)]
|
||||
public int TimeoutMs { get; set; }
|
||||
|
||||
/// <summary>Poll interval in milliseconds (default 100 if <= 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
|
||||
// Expose internal members to test assembly
|
||||
[assembly: InternalsVisibleTo("ZB.MOM.WW.LmxProxy.Client.Tests")]
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
25
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config
Normal file
25
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/App.config
Normal 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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
124
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs
Normal file
124
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Quality.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
129
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs
Normal file
129
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Domain/Vtq.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
87
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs
Normal file
87
lmxproxy/src-reference/ZB.MOM.WW.LmxProxy.Host/Program.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user