deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/
LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL adapter files, and related docs to deprecated/. Removed LmxProxy registration from DataConnectionFactory, project reference from DCL, protocol option from UI, and cleaned up all requirement docs.
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the LmxProxy client, typically set via the builder.
|
||||
/// </summary>
|
||||
internal class ClientConfiguration
|
||||
{
|
||||
/// <summary>Maximum number of retry attempts for transient failures.</summary>
|
||||
public int MaxRetryAttempts { get; set; }
|
||||
|
||||
/// <summary>Base delay between retries (exponential backoff applied).</summary>
|
||||
public TimeSpan RetryDelay { get; set; }
|
||||
|
||||
/// <summary>Whether client-side metrics collection is enabled.</summary>
|
||||
public bool EnableMetrics { get; set; }
|
||||
|
||||
/// <summary>Optional header name for correlation ID propagation.</summary>
|
||||
public string? CorrelationIdHeader { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// TLS configuration for the LmxProxy gRPC client.
|
||||
/// </summary>
|
||||
public class ClientTlsConfiguration
|
||||
{
|
||||
/// <summary>Whether to use TLS for the gRPC connection.</summary>
|
||||
public bool UseTls { get; set; } = false;
|
||||
|
||||
/// <summary>Path to the client certificate PEM file for mTLS.</summary>
|
||||
public string? ClientCertificatePath { get; set; }
|
||||
|
||||
/// <summary>Path to the client private key PEM file for mTLS.</summary>
|
||||
public string? ClientKeyPath { get; set; }
|
||||
|
||||
/// <summary>Path to the server CA certificate PEM file for custom trust.</summary>
|
||||
public string? ServerCaCertificatePath { get; set; }
|
||||
|
||||
/// <summary>Override the server name used for TLS verification.</summary>
|
||||
public string? ServerNameOverride { get; set; }
|
||||
|
||||
/// <summary>Whether to validate the server certificate.</summary>
|
||||
public bool ValidateServerCertificate { get; set; } = true;
|
||||
|
||||
/// <summary>Whether to allow self-signed certificates.</summary>
|
||||
public bool AllowSelfSignedCertificates { get; set; } = false;
|
||||
|
||||
/// <summary>Whether to ignore all certificate errors (dangerous).</summary>
|
||||
public bool IgnoreAllCertificateErrors { get; set; } = false;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>Represents the state of a connection to the LmxProxy service.</summary>
|
||||
public enum ConnectionState
|
||||
{
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnecting,
|
||||
Error,
|
||||
Reconnecting
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// OPC-style quality codes for SCADA data values.
|
||||
/// Byte value matches OPC DA quality low byte for direct round-trip.
|
||||
/// </summary>
|
||||
public enum Quality : byte
|
||||
{
|
||||
// ─────────────── Bad family (0-31) ───────────────
|
||||
Bad = 0,
|
||||
Bad_ConfigError = 4,
|
||||
Bad_NotConnected = 8,
|
||||
Bad_DeviceFailure = 12,
|
||||
Bad_SensorFailure = 16,
|
||||
Bad_LastKnownValue = 20,
|
||||
Bad_CommFailure = 24,
|
||||
Bad_OutOfService = 28,
|
||||
Bad_WaitingForInitialData = 32,
|
||||
|
||||
// ──────────── Uncertain family (64-95) ───────────
|
||||
Uncertain = 64,
|
||||
Uncertain_LowLimited = 65,
|
||||
Uncertain_HighLimited = 66,
|
||||
Uncertain_Constant = 67,
|
||||
Uncertain_LastUsable = 68,
|
||||
Uncertain_LastUsable_LL = 69,
|
||||
Uncertain_LastUsable_HL = 70,
|
||||
Uncertain_LastUsable_Cnst = 71,
|
||||
Uncertain_SensorNotAcc = 80,
|
||||
Uncertain_SensorNotAcc_LL = 81,
|
||||
Uncertain_SensorNotAcc_HL = 82,
|
||||
Uncertain_SensorNotAcc_C = 83,
|
||||
Uncertain_EuExceeded = 84,
|
||||
Uncertain_EuExceeded_LL = 85,
|
||||
Uncertain_EuExceeded_HL = 86,
|
||||
Uncertain_EuExceeded_C = 87,
|
||||
Uncertain_SubNormal = 88,
|
||||
Uncertain_SubNormal_LL = 89,
|
||||
Uncertain_SubNormal_HL = 90,
|
||||
Uncertain_SubNormal_C = 91,
|
||||
|
||||
// ─────────────── Good family (192-219) ────────────
|
||||
Good = 192,
|
||||
Good_LowLimited = 193,
|
||||
Good_HighLimited = 194,
|
||||
Good_Constant = 195,
|
||||
Good_LocalOverride = 216,
|
||||
Good_LocalOverride_LL = 217,
|
||||
Good_LocalOverride_HL = 218,
|
||||
Good_LocalOverride_C = 219
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>Extension methods for <see cref="Quality"/>.</summary>
|
||||
public static class QualityExtensions
|
||||
{
|
||||
/// <summary>Returns true if quality is in the Good family (byte >= 192).</summary>
|
||||
public static bool IsGood(this Quality q) => (byte)q >= 192;
|
||||
|
||||
/// <summary>Returns true if quality is in the Uncertain family (byte 64-127).</summary>
|
||||
public static bool IsUncertain(this Quality q) => (byte)q is >= 64 and < 128;
|
||||
|
||||
/// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
|
||||
public static bool IsBad(this Quality q) => (byte)q < 64;
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA 32-bit status code to the simplified <see cref="Quality"/> enum.
|
||||
/// Uses the top two bits to determine the quality family.
|
||||
/// </summary>
|
||||
public static Quality FromStatusCode(uint statusCode)
|
||||
{
|
||||
uint category = statusCode & 0xC0000000;
|
||||
return category switch
|
||||
{
|
||||
0x00000000 => Quality.Good,
|
||||
0x40000000 => Quality.Uncertain,
|
||||
_ => Quality.Bad
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,499 @@
|
||||
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
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[ServiceContract(Name = "scada.ScadaService")]
|
||||
public interface IScadaService
|
||||
{
|
||||
ValueTask<ConnectResponse> ConnectAsync(ConnectRequest request);
|
||||
ValueTask<DisconnectResponse> DisconnectAsync(DisconnectRequest request);
|
||||
ValueTask<GetConnectionStateResponse> GetConnectionStateAsync(GetConnectionStateRequest request);
|
||||
ValueTask<ReadResponse> ReadAsync(ReadRequest request);
|
||||
ValueTask<ReadBatchResponse> ReadBatchAsync(ReadBatchRequest request);
|
||||
ValueTask<WriteResponse> WriteAsync(WriteRequest request);
|
||||
ValueTask<WriteBatchResponse> WriteBatchAsync(WriteBatchRequest request);
|
||||
ValueTask<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(WriteBatchAndWaitRequest request);
|
||||
IAsyncEnumerable<VtqMessage> SubscribeAsync(SubscribeRequest request, CancellationToken cancellationToken = default);
|
||||
ValueTask<CheckApiKeyResponse> CheckApiKeyAsync(CheckApiKeyRequest request);
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Typed Value System (v2)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Carries a value in its native type via a protobuf oneof.
|
||||
/// Exactly one property will be non-default. All-default = null value.
|
||||
/// protobuf-net uses the first non-default field in field-number order for oneof.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class TypedValue
|
||||
{
|
||||
// Tracks which oneof field was set (by property setter during deserialization or manual assignment).
|
||||
private TypedValueCase _setCase = TypedValueCase.None;
|
||||
|
||||
private bool _boolValue;
|
||||
private int _int32Value;
|
||||
private long _int64Value;
|
||||
private float _floatValue;
|
||||
private double _doubleValue;
|
||||
private string? _stringValue;
|
||||
private byte[]? _bytesValue;
|
||||
private long _datetimeValue;
|
||||
private ArrayValue? _arrayValue;
|
||||
|
||||
[DataMember(Order = 1)]
|
||||
public bool BoolValue { get => _boolValue; set { _boolValue = value; _setCase = TypedValueCase.BoolValue; } }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public int Int32Value { get => _int32Value; set { _int32Value = value; _setCase = TypedValueCase.Int32Value; } }
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public long Int64Value { get => _int64Value; set { _int64Value = value; _setCase = TypedValueCase.Int64Value; } }
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public float FloatValue { get => _floatValue; set { _floatValue = value; _setCase = TypedValueCase.FloatValue; } }
|
||||
|
||||
[DataMember(Order = 5)]
|
||||
public double DoubleValue { get => _doubleValue; set { _doubleValue = value; _setCase = TypedValueCase.DoubleValue; } }
|
||||
|
||||
[DataMember(Order = 6)]
|
||||
public string? StringValue { get => _stringValue; set { _stringValue = value; _setCase = TypedValueCase.StringValue; } }
|
||||
|
||||
[DataMember(Order = 7)]
|
||||
public byte[]? BytesValue { get => _bytesValue; set { _bytesValue = value; _setCase = TypedValueCase.BytesValue; } }
|
||||
|
||||
[DataMember(Order = 8)]
|
||||
public long DatetimeValue { get => _datetimeValue; set { _datetimeValue = value; _setCase = TypedValueCase.DatetimeValue; } }
|
||||
|
||||
[DataMember(Order = 9)]
|
||||
public ArrayValue? ArrayValue { get => _arrayValue; set { _arrayValue = value; _setCase = TypedValueCase.ArrayValue; } }
|
||||
|
||||
/// <summary>
|
||||
/// Indicates which oneof case is set. Tracked via property setters so default values
|
||||
/// (false, 0, 0.0) are correctly distinguished from "not set".
|
||||
/// </summary>
|
||||
public TypedValueCase GetValueCase() => _setCase;
|
||||
}
|
||||
|
||||
/// <summary>Identifies which field in TypedValue is set.</summary>
|
||||
public enum TypedValueCase
|
||||
{
|
||||
None = 0,
|
||||
BoolValue = 1,
|
||||
Int32Value = 2,
|
||||
Int64Value = 3,
|
||||
FloatValue = 4,
|
||||
DoubleValue = 5,
|
||||
StringValue = 6,
|
||||
BytesValue = 7,
|
||||
DatetimeValue = 8,
|
||||
ArrayValue = 9
|
||||
}
|
||||
|
||||
/// <summary>Container for typed arrays. Exactly one field will be set.</summary>
|
||||
[DataContract]
|
||||
public class ArrayValue
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public BoolArray? BoolValues { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public Int32Array? Int32Values { get; set; }
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public Int64Array? Int64Values { get; set; }
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public FloatArray? FloatValues { get; set; }
|
||||
|
||||
[DataMember(Order = 5)]
|
||||
public DoubleArray? DoubleValues { get; set; }
|
||||
|
||||
[DataMember(Order = 6)]
|
||||
public StringArray? StringValues { get; set; }
|
||||
|
||||
[DataMember(Order = 7)]
|
||||
public DatetimeArray? DatetimeValues { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class BoolArray
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<bool> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class Int32Array
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<int> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class Int64Array
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<long> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class FloatArray
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<float> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class DoubleArray
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<double> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class StringArray
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<string> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class DatetimeArray
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public List<long> Values { get; set; } = [];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Quality Code (v2)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// OPC UA-style quality code with numeric status code and symbolic name.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
public class QualityCode
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public uint StatusCode { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string SymbolicName { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Returns true if quality category is Good (high bits 0x00).</summary>
|
||||
public bool IsGood => (StatusCode & 0xC0000000) == 0x00000000;
|
||||
|
||||
/// <summary>Returns true if quality category is Uncertain (high bits 0x40).</summary>
|
||||
public bool IsUncertain => (StatusCode & 0xC0000000) == 0x40000000;
|
||||
|
||||
/// <summary>Returns true if quality category is Bad (high bits 0x80).</summary>
|
||||
public bool IsBad => (StatusCode & 0xC0000000) == 0x80000000;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// VTQ message (v2)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class VtqMessage
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public TypedValue? Value { get; set; }
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public long TimestampUtcTicks { get; set; }
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public QualityCode? Quality { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Connect
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class ConnectRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class ConnectResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Disconnect
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class DisconnectRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class DisconnectResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// GetConnectionState
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class GetConnectionStateRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class GetConnectionStateResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool IsConnected { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string ClientId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public long ConnectedSinceUtcTicks { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Read
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class ReadRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class ReadResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public VtqMessage? Vtq { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class ReadBatchRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class ReadBatchResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public List<VtqMessage> Vtqs { get; set; } = [];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Write
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class WriteRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public TypedValue? Value { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class WriteResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class WriteItem
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public TypedValue? Value { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class WriteResult
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string Tag { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class WriteBatchRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public List<WriteItem> Items { get; set; } = [];
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class WriteBatchResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public List<WriteResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WriteBatchAndWait
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class WriteBatchAndWaitRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public List<WriteItem> Items { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public string FlagTag { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public TypedValue? FlagValue { get; set; }
|
||||
|
||||
[DataMember(Order = 5)]
|
||||
public int TimeoutMs { get; set; }
|
||||
|
||||
[DataMember(Order = 6)]
|
||||
public int PollIntervalMs { get; set; }
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class WriteBatchAndWaitResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool Success { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public List<WriteResult> WriteResults { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 4)]
|
||||
public bool FlagReached { get; set; }
|
||||
|
||||
[DataMember(Order = 5)]
|
||||
public int ElapsedMs { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Subscribe
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class SubscribeRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public List<string> Tags { get; set; } = [];
|
||||
|
||||
[DataMember(Order = 3)]
|
||||
public int SamplingMs { get; set; }
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// CheckApiKey
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[DataContract]
|
||||
public class CheckApiKeyRequest
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public string ApiKey { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public class CheckApiKeyResponse
|
||||
{
|
||||
[DataMember(Order = 1)]
|
||||
public bool IsValid { get; set; }
|
||||
|
||||
[DataMember(Order = 2)]
|
||||
public string Message { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
/// <summary>Value, Timestamp, and Quality for SCADA data.</summary>
|
||||
public readonly record struct Vtq(object? Value, DateTime Timestamp, Quality Quality)
|
||||
{
|
||||
public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
|
||||
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
|
||||
public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);
|
||||
|
||||
public override string ToString() =>
|
||||
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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 (range: 1s to 10min).</summary>
|
||||
TimeSpan DefaultTimeout { get; set; }
|
||||
|
||||
/// <summary>Connects to the LmxProxy service and establishes a session.</summary>
|
||||
Task ConnectAsync(CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Disconnects from the LmxProxy service.</summary>
|
||||
Task DisconnectAsync();
|
||||
|
||||
/// <summary>Returns true if the client has an active session.</summary>
|
||||
Task<bool> IsConnectedAsync();
|
||||
|
||||
/// <summary>Reads a single tag value.</summary>
|
||||
Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Reads multiple tag values in a single batch.</summary>
|
||||
Task<IDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes a single tag value (native TypedValue -- no string heuristics).</summary>
|
||||
Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Writes multiple tag values in a single batch.</summary>
|
||||
Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of values, then polls a flag tag until it matches or timeout expires.
|
||||
/// Returns (writeResults, flagReached, elapsedMs).
|
||||
/// </summary>
|
||||
Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values,
|
||||
string flagTag,
|
||||
TypedValue flagValue,
|
||||
int timeoutMs = 5000,
|
||||
int pollIntervalMs = 100,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Subscribes to tag updates with value and error callbacks.</summary>
|
||||
Task<LmxProxyClient.ISubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Validates an API key and returns info.</summary>
|
||||
Task<LmxProxyClient.ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>Returns a snapshot of client-side metrics.</summary>
|
||||
Dictionary<string, object> GetMetrics();
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating <see cref="LmxProxyClient"/> instances.
|
||||
/// </summary>
|
||||
public interface ILmxProxyClientFactory
|
||||
{
|
||||
/// <summary>Creates a client from the default "LmxProxy" configuration section.</summary>
|
||||
LmxProxyClient CreateClient();
|
||||
|
||||
/// <summary>Creates a client from a named configuration section.</summary>
|
||||
LmxProxyClient CreateClient(string configName);
|
||||
|
||||
/// <summary>Creates a client using a builder configuration action.</summary>
|
||||
LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Default implementation of <see cref="ILmxProxyClientFactory"/> that reads from IConfiguration.
|
||||
/// </summary>
|
||||
public class LmxProxyClientFactory : ILmxProxyClientFactory
|
||||
{
|
||||
private readonly IConfiguration _configuration;
|
||||
|
||||
/// <summary>Creates a new factory with the specified configuration.</summary>
|
||||
public LmxProxyClientFactory(IConfiguration configuration)
|
||||
{
|
||||
_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LmxProxyClient CreateClient() => CreateClient("LmxProxy");
|
||||
|
||||
/// <inheritdoc />
|
||||
public LmxProxyClient CreateClient(string configName)
|
||||
{
|
||||
IConfigurationSection section = _configuration.GetSection(configName);
|
||||
var options = new LmxProxyClientOptions();
|
||||
section.Bind(options);
|
||||
return BuildFromOptions(options);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public LmxProxyClient CreateClient(Action<LmxProxyClientBuilder> builderAction)
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
builderAction(builder);
|
||||
return builder.Build();
|
||||
}
|
||||
|
||||
private static LmxProxyClient BuildFromOptions(LmxProxyClientOptions options)
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder()
|
||||
.WithHost(options.Host)
|
||||
.WithPort(options.Port)
|
||||
.WithTimeout(options.Timeout)
|
||||
.WithRetryPolicy(options.Retry.MaxAttempts, options.Retry.Delay);
|
||||
|
||||
if (!string.IsNullOrEmpty(options.ApiKey))
|
||||
builder.WithApiKey(options.ApiKey);
|
||||
|
||||
if (options.EnableMetrics)
|
||||
builder.WithMetrics();
|
||||
|
||||
if (!string.IsNullOrEmpty(options.CorrelationIdHeader))
|
||||
builder.WithCorrelationIdHeader(options.CorrelationIdHeader);
|
||||
|
||||
if (options.UseSsl)
|
||||
{
|
||||
builder.WithTlsConfiguration(new ClientTlsConfiguration
|
||||
{
|
||||
UseTls = true,
|
||||
ServerCaCertificatePath = options.CertificatePath
|
||||
});
|
||||
}
|
||||
|
||||
return builder.Build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Result of an API key validation check.
|
||||
/// </summary>
|
||||
public class ApiKeyInfo
|
||||
{
|
||||
/// <summary>Whether the API key is valid.</summary>
|
||||
public bool IsValid { get; init; }
|
||||
|
||||
/// <summary>Role associated with the API key.</summary>
|
||||
public string? Role { get; init; }
|
||||
|
||||
/// <summary>Description or message from the server.</summary>
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks per-operation counts, errors, and latency with rolling buffer and percentile support.
|
||||
/// </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 Lock _latencyLock = new();
|
||||
|
||||
public void IncrementOperationCount(string operation)
|
||||
{
|
||||
_operationCounts.AddOrUpdate(operation, 1, (_, count) => count + 1);
|
||||
}
|
||||
|
||||
public void IncrementErrorCount(string operation)
|
||||
{
|
||||
_errorCounts.AddOrUpdate(operation, 1, (_, count) => count + 1);
|
||||
}
|
||||
|
||||
public void RecordLatency(string operation, long milliseconds)
|
||||
{
|
||||
lock (_latencyLock)
|
||||
{
|
||||
if (!_latencies.TryGetValue(operation, out var list))
|
||||
{
|
||||
list = [];
|
||||
_latencies[operation] = list;
|
||||
}
|
||||
list.Add(milliseconds);
|
||||
if (list.Count > 1000)
|
||||
{
|
||||
list.RemoveAt(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Dictionary<string, object> GetSnapshot()
|
||||
{
|
||||
var snapshot = new Dictionary<string, object>();
|
||||
|
||||
foreach (var kvp in _operationCounts)
|
||||
{
|
||||
snapshot[$"{kvp.Key}_count"] = kvp.Value;
|
||||
}
|
||||
|
||||
foreach (var kvp in _errorCounts)
|
||||
{
|
||||
snapshot[$"{kvp.Key}_errors"] = kvp.Value;
|
||||
}
|
||||
|
||||
lock (_latencyLock)
|
||||
{
|
||||
foreach (var kvp in _latencies)
|
||||
{
|
||||
var values = kvp.Value;
|
||||
if (values.Count == 0) continue;
|
||||
|
||||
double avg = values.Average();
|
||||
snapshot[$"{kvp.Key}_avg_latency_ms"] = Math.Round(avg, 2);
|
||||
snapshot[$"{kvp.Key}_p95_latency_ms"] = GetPercentile(values, 95);
|
||||
snapshot[$"{kvp.Key}_p99_latency_ms"] = GetPercentile(values, 99);
|
||||
}
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static long GetPercentile(List<long> values, int percentile)
|
||||
{
|
||||
var sorted = values.OrderBy(v => v).ToList();
|
||||
int index = Math.Max(0, (int)Math.Ceiling(percentile / 100.0 * sorted.Count) - 1);
|
||||
return sorted[index];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 Action<Exception>? _onStreamError;
|
||||
private readonly ILogger<LmxProxyClient> _logger;
|
||||
private readonly Action<ISubscription>? _onDispose;
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _processingTask;
|
||||
private bool _disposed;
|
||||
private bool _streamErrorFired;
|
||||
|
||||
public CodeFirstSubscription(
|
||||
IScadaService client,
|
||||
string sessionId,
|
||||
List<string> tags,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError,
|
||||
ILogger<LmxProxyClient> logger,
|
||||
Action<ISubscription>? onDispose)
|
||||
{
|
||||
_client = client;
|
||||
_sessionId = sessionId;
|
||||
_tags = tags;
|
||||
_onUpdate = onUpdate;
|
||||
_onStreamError = onStreamError;
|
||||
_logger = logger;
|
||||
_onDispose = onDispose;
|
||||
}
|
||||
|
||||
public Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_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 vtqMsg in _client.SubscribeAsync(request, linkedCts.Token))
|
||||
{
|
||||
try
|
||||
{
|
||||
Vtq vtq = ConvertVtqMessage(vtqMsg);
|
||||
_onUpdate(vtqMsg.Tag, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing subscription update for {Tag}", vtqMsg.Tag);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_cts.IsCancellationRequested || cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogDebug("Subscription cancelled");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in subscription processing");
|
||||
FireStreamError(ex);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_disposed)
|
||||
{
|
||||
_disposed = true;
|
||||
_onDispose?.Invoke(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void FireStreamError(Exception ex)
|
||||
{
|
||||
if (_streamErrorFired) return;
|
||||
_streamErrorFired = true;
|
||||
try { _onStreamError?.Invoke(ex); }
|
||||
catch (Exception cbEx) { _logger.LogWarning(cbEx, "onStreamError callback threw"); }
|
||||
}
|
||||
|
||||
public async Task DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
await _cts.CancelAsync();
|
||||
|
||||
if (_processingTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _processingTask.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { /* swallow timeout or cancellation */ }
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try
|
||||
{
|
||||
DisposeAsync().Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public async Task ConnectAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await _connectionLock.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
if (IsConnected)
|
||||
return;
|
||||
|
||||
var endpoint = BuildEndpointUri();
|
||||
_logger.LogInformation("Connecting to LmxProxy at {Endpoint}", endpoint);
|
||||
|
||||
GrpcChannel channel = GrpcChannelFactory.CreateChannel(endpoint, _tlsConfiguration, _logger, _apiKey);
|
||||
IScadaService client;
|
||||
try
|
||||
{
|
||||
client = channel.CreateGrpcService<IScadaService>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
channel.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
ConnectResponse response;
|
||||
try
|
||||
{
|
||||
var request = new ConnectRequest
|
||||
{
|
||||
ClientId = $"ScadaBridge-{Guid.NewGuid():N}",
|
||||
ApiKey = _apiKey ?? string.Empty
|
||||
};
|
||||
response = await client.ConnectAsync(request);
|
||||
}
|
||||
catch
|
||||
{
|
||||
channel.Dispose();
|
||||
throw;
|
||||
}
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
channel.Dispose();
|
||||
throw new InvalidOperationException($"Connect failed: {response.Message}");
|
||||
}
|
||||
|
||||
_channel = channel;
|
||||
_client = client;
|
||||
_sessionId = response.SessionId;
|
||||
_isConnected = true;
|
||||
|
||||
StartKeepAlive();
|
||||
|
||||
_logger.LogInformation("Connected to LmxProxy, session={SessionId}", _sessionId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_channel = null;
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_isConnected = false;
|
||||
_logger.LogError(ex, "Failed to connect to LmxProxy");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task DisconnectAsync()
|
||||
{
|
||||
await _connectionLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
StopKeepAlive();
|
||||
|
||||
if (_client is not null && !string.IsNullOrEmpty(_sessionId))
|
||||
{
|
||||
try
|
||||
{
|
||||
await _client.DisconnectAsync(new DisconnectRequest { SessionId = _sessionId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Error sending disconnect request");
|
||||
}
|
||||
}
|
||||
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_isConnected = false;
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="SubscribeAsync"/>
|
||||
public async Task<ISubscription> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> onUpdate,
|
||||
Action<Exception>? onStreamError = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
|
||||
var subscription = new CodeFirstSubscription(
|
||||
_client!,
|
||||
_sessionId,
|
||||
addresses.ToList(),
|
||||
onUpdate,
|
||||
onStreamError,
|
||||
_logger,
|
||||
sub =>
|
||||
{
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeSubscriptions.Remove(sub);
|
||||
}
|
||||
});
|
||||
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
_activeSubscriptions.Add(subscription);
|
||||
}
|
||||
|
||||
await subscription.StartAsync(cancellationToken);
|
||||
return subscription;
|
||||
}
|
||||
|
||||
private void StartKeepAlive()
|
||||
{
|
||||
_keepAliveTimer = new Timer(
|
||||
async _ => await KeepAliveCallback(),
|
||||
null,
|
||||
_keepAliveInterval,
|
||||
_keepAliveInterval);
|
||||
}
|
||||
|
||||
private async Task KeepAliveCallback()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_client is null || string.IsNullOrEmpty(_sessionId))
|
||||
return;
|
||||
|
||||
await _client.GetConnectionStateAsync(new GetConnectionStateRequest { SessionId = _sessionId });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Keep-alive failed, marking disconnected");
|
||||
StopKeepAlive();
|
||||
await MarkDisconnectedAsync(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void StopKeepAlive()
|
||||
{
|
||||
_keepAliveTimer?.Dispose();
|
||||
_keepAliveTimer = null;
|
||||
}
|
||||
|
||||
internal async Task MarkDisconnectedAsync(Exception ex)
|
||||
{
|
||||
if (_disposed) return;
|
||||
|
||||
await _connectionLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
_isConnected = false;
|
||||
_client = null;
|
||||
_sessionId = string.Empty;
|
||||
_channel?.Dispose();
|
||||
_channel = null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_connectionLock.Release();
|
||||
}
|
||||
|
||||
List<ISubscription> subscriptions;
|
||||
lock (_subscriptionLock)
|
||||
{
|
||||
subscriptions = [.. _activeSubscriptions];
|
||||
_activeSubscriptions.Clear();
|
||||
}
|
||||
|
||||
foreach (var sub in subscriptions)
|
||||
{
|
||||
try { sub.Dispose(); }
|
||||
catch { /* swallow */ }
|
||||
}
|
||||
|
||||
_logger.LogWarning(ex, "Client marked as disconnected");
|
||||
}
|
||||
|
||||
private Uri BuildEndpointUri()
|
||||
{
|
||||
string scheme = _tlsConfiguration?.UseTls == true ? Uri.UriSchemeHttps : Uri.UriSchemeHttp;
|
||||
return new UriBuilder { Scheme = scheme, Host = _host, Port = _port }.Uri;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
public partial class LmxProxyClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents an active tag subscription. Dispose to unsubscribe.
|
||||
/// </summary>
|
||||
public interface ISubscription : IDisposable
|
||||
{
|
||||
/// <summary>Asynchronous disposal with cancellation support.</summary>
|
||||
Task DisposeAsync();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System.Diagnostics;
|
||||
using Grpc.Core;
|
||||
using Grpc.Net.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// gRPC client for the LmxProxy SCADA proxy service. Uses v2 protocol with native TypedValue.
|
||||
/// </summary>
|
||||
public partial class LmxProxyClient : ILmxProxyClient
|
||||
{
|
||||
private readonly ILogger<LmxProxyClient> _logger;
|
||||
private readonly string _host;
|
||||
private readonly int _port;
|
||||
private readonly string? _apiKey;
|
||||
private readonly ClientTlsConfiguration? _tlsConfiguration;
|
||||
private readonly ClientMetrics _metrics = new();
|
||||
private readonly SemaphoreSlim _connectionLock = new(1, 1);
|
||||
private readonly List<ISubscription> _activeSubscriptions = [];
|
||||
private readonly Lock _subscriptionLock = new();
|
||||
|
||||
private GrpcChannel? _channel;
|
||||
private IScadaService? _client;
|
||||
private string _sessionId = string.Empty;
|
||||
private bool _disposed;
|
||||
private bool _isConnected;
|
||||
private TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
|
||||
private ClientConfiguration? _configuration;
|
||||
private ResiliencePipeline? _resiliencePipeline;
|
||||
private Timer? _keepAliveTimer;
|
||||
private readonly TimeSpan _keepAliveInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Returns true if the client has an active session and is not disposed.</summary>
|
||||
public bool IsConnected => !_disposed && _isConnected && !string.IsNullOrEmpty(_sessionId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public TimeSpan DefaultTimeout
|
||||
{
|
||||
get => _defaultTimeout;
|
||||
set
|
||||
{
|
||||
if (value < TimeSpan.FromSeconds(1) || value > TimeSpan.FromMinutes(10))
|
||||
throw new ArgumentOutOfRangeException(nameof(value), "DefaultTimeout must be between 1 second and 10 minutes.");
|
||||
_defaultTimeout = value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new LmxProxyClient instance.
|
||||
/// </summary>
|
||||
public LmxProxyClient(
|
||||
string host, int port, string? apiKey,
|
||||
ClientTlsConfiguration? tlsConfiguration,
|
||||
ILogger<LmxProxyClient>? logger = null)
|
||||
{
|
||||
_host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
_port = port;
|
||||
_apiKey = apiKey;
|
||||
_tlsConfiguration = tlsConfiguration;
|
||||
_logger = logger ?? NullLogger<LmxProxyClient>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets builder configuration including retry policies. Called internally by the builder.
|
||||
/// </summary>
|
||||
internal void SetBuilderConfiguration(ClientConfiguration config)
|
||||
{
|
||||
_configuration = config;
|
||||
if (config.MaxRetryAttempts > 0)
|
||||
{
|
||||
_resiliencePipeline = new ResiliencePipelineBuilder()
|
||||
.AddRetry(new RetryStrategyOptions
|
||||
{
|
||||
MaxRetryAttempts = config.MaxRetryAttempts,
|
||||
Delay = config.RetryDelay,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
ShouldHandle = new PredicateBuilder()
|
||||
.Handle<RpcException>(ex =>
|
||||
ex.StatusCode == StatusCode.Unavailable ||
|
||||
ex.StatusCode == StatusCode.DeadlineExceeded ||
|
||||
ex.StatusCode == StatusCode.ResourceExhausted ||
|
||||
ex.StatusCode == StatusCode.Aborted),
|
||||
OnRetry = args =>
|
||||
{
|
||||
_logger.LogWarning("Retry {Attempt} after {Delay} for {Exception}",
|
||||
args.AttemptNumber, args.RetryDelay, args.Outcome.Exception?.Message);
|
||||
return ValueTask.CompletedTask;
|
||||
}
|
||||
})
|
||||
.Build();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<Vtq> ReadAsync(string address, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("Read");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new ReadRequest { SessionId = _sessionId, Tag = address };
|
||||
ReadResponse response = await ExecuteWithRetry(
|
||||
() => _client!.ReadAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Read failed: {response.Message}");
|
||||
return ConvertVtqMessage(response.Vtq);
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("Read");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("Read", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IDictionary<string, Vtq>> ReadBatchAsync(
|
||||
IEnumerable<string> addresses, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("ReadBatch");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new ReadBatchRequest { SessionId = _sessionId, Tags = addresses.ToList() };
|
||||
ReadBatchResponse response = await ExecuteWithRetry(
|
||||
() => _client!.ReadBatchAsync(request).AsTask(), cancellationToken);
|
||||
var result = new Dictionary<string, Vtq>();
|
||||
foreach (var vtqMsg in response.Vtqs)
|
||||
{
|
||||
result[vtqMsg.Tag] = ConvertVtqMessage(vtqMsg);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("ReadBatch");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("ReadBatch", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteAsync(string address, TypedValue value, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("Write");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new WriteRequest { SessionId = _sessionId, Tag = address, Value = value };
|
||||
WriteResponse response = await ExecuteWithRetry(
|
||||
() => _client!.WriteAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"Write failed: {response.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("Write");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("Write", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task WriteBatchAsync(IDictionary<string, TypedValue> values, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
_metrics.IncrementOperationCount("WriteBatch");
|
||||
var sw = Stopwatch.StartNew();
|
||||
try
|
||||
{
|
||||
var request = new WriteBatchRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList()
|
||||
};
|
||||
WriteBatchResponse response = await ExecuteWithRetry(
|
||||
() => _client!.WriteBatchAsync(request).AsTask(), cancellationToken);
|
||||
if (!response.Success)
|
||||
throw new InvalidOperationException($"WriteBatch failed: {response.Message}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
_metrics.IncrementErrorCount("WriteBatch");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
sw.Stop();
|
||||
_metrics.RecordLatency("WriteBatch", sw.ElapsedMilliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<WriteBatchAndWaitResponse> WriteBatchAndWaitAsync(
|
||||
IDictionary<string, TypedValue> values, string flagTag, TypedValue flagValue,
|
||||
int timeoutMs = 5000, int pollIntervalMs = 100, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var request = new WriteBatchAndWaitRequest
|
||||
{
|
||||
SessionId = _sessionId,
|
||||
Items = values.Select(kv => new WriteItem { Tag = kv.Key, Value = kv.Value }).ToList(),
|
||||
FlagTag = flagTag,
|
||||
FlagValue = flagValue,
|
||||
TimeoutMs = timeoutMs,
|
||||
PollIntervalMs = pollIntervalMs
|
||||
};
|
||||
return await ExecuteWithRetry(
|
||||
() => _client!.WriteBatchAndWaitAsync(request).AsTask(), cancellationToken);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ApiKeyInfo> CheckApiKeyAsync(string apiKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
EnsureConnected();
|
||||
var request = new CheckApiKeyRequest { ApiKey = apiKey };
|
||||
CheckApiKeyResponse response = await _client!.CheckApiKeyAsync(request);
|
||||
return new ApiKeyInfo { IsValid = response.IsValid, Description = response.Message };
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsConnectedAsync() => Task.FromResult(IsConnected);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Dictionary<string, object> GetMetrics() => _metrics.GetSnapshot();
|
||||
|
||||
internal static Vtq ConvertVtqMessage(VtqMessage? msg)
|
||||
{
|
||||
if (msg is null)
|
||||
return new Vtq(null, DateTime.UtcNow, Quality.Bad);
|
||||
|
||||
object? value = ExtractTypedValue(msg.Value);
|
||||
DateTime timestamp = msg.TimestampUtcTicks > 0
|
||||
? new DateTime(msg.TimestampUtcTicks, DateTimeKind.Utc)
|
||||
: DateTime.UtcNow;
|
||||
Quality quality = QualityExtensions.FromStatusCode(msg.Quality?.StatusCode ?? 0x80000000u);
|
||||
return new Vtq(value, timestamp, quality);
|
||||
}
|
||||
|
||||
internal static object? ExtractTypedValue(TypedValue? tv)
|
||||
{
|
||||
if (tv is null) return null;
|
||||
|
||||
return tv.GetValueCase() switch
|
||||
{
|
||||
TypedValueCase.BoolValue => tv.BoolValue,
|
||||
TypedValueCase.Int32Value => tv.Int32Value,
|
||||
TypedValueCase.Int64Value => tv.Int64Value,
|
||||
TypedValueCase.FloatValue => tv.FloatValue,
|
||||
TypedValueCase.DoubleValue => tv.DoubleValue,
|
||||
TypedValueCase.StringValue => tv.StringValue,
|
||||
TypedValueCase.BytesValue => tv.BytesValue,
|
||||
TypedValueCase.DatetimeValue => new DateTime(tv.DatetimeValue, DateTimeKind.Utc),
|
||||
TypedValueCase.ArrayValue => ExtractArrayValue(tv.ArrayValue),
|
||||
TypedValueCase.None => null,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
internal static object? ExtractArrayValue(ArrayValue? av)
|
||||
{
|
||||
if (av is null) return null;
|
||||
if (av.BoolValues is not null) return av.BoolValues.Values.ToArray();
|
||||
if (av.Int32Values is not null) return av.Int32Values.Values.ToArray();
|
||||
if (av.Int64Values is not null) return av.Int64Values.Values.ToArray();
|
||||
if (av.FloatValues is not null) return av.FloatValues.Values.ToArray();
|
||||
if (av.DoubleValues is not null) return av.DoubleValues.Values.ToArray();
|
||||
if (av.StringValues is not null) return av.StringValues.Values.ToArray();
|
||||
if (av.DatetimeValues is not null)
|
||||
{
|
||||
return av.DatetimeValues.Values
|
||||
.Select(ticks => new DateTime(ticks, DateTimeKind.Utc))
|
||||
.ToArray();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<T> ExecuteWithRetry<T>(Func<Task<T>> operation, CancellationToken ct)
|
||||
{
|
||||
if (_resiliencePipeline is not null)
|
||||
{
|
||||
return await _resiliencePipeline.ExecuteAsync(
|
||||
async token => await operation(), ct);
|
||||
}
|
||||
return await operation();
|
||||
}
|
||||
|
||||
private void EnsureConnected()
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Client is not connected. Call ConnectAsync first.");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_keepAliveTimer?.Dispose();
|
||||
_channel?.Dispose();
|
||||
_connectionLock.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
try { await DisconnectAsync(); } catch { /* swallow */ }
|
||||
Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Fluent builder for creating configured <see cref="LmxProxyClient"/> instances.
|
||||
/// </summary>
|
||||
public class LmxProxyClientBuilder
|
||||
{
|
||||
private string? _host;
|
||||
private int _port = 50051;
|
||||
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 of the LmxProxy server. Required.</summary>
|
||||
public LmxProxyClientBuilder WithHost(string host)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(host))
|
||||
throw new ArgumentException("Host must not be null or empty.", nameof(host));
|
||||
_host = host;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the port of the LmxProxy server. Default is 50051.</summary>
|
||||
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>
|
||||
public LmxProxyClientBuilder WithApiKey(string? apiKey)
|
||||
{
|
||||
_apiKey = apiKey;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the logger instance for the client.</summary>
|
||||
public LmxProxyClientBuilder WithLogger(ILogger<LmxProxyClient> logger)
|
||||
{
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the default timeout for operations. Must be between 1 second and 10 minutes.</summary>
|
||||
public LmxProxyClientBuilder WithTimeout(TimeSpan timeout)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero || timeout > TimeSpan.FromMinutes(10))
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout), "Timeout must be greater than zero and at most 10 minutes.");
|
||||
_defaultTimeout = timeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Enables TLS with an optional server CA certificate path.</summary>
|
||||
public LmxProxyClientBuilder WithSslCredentials(string? certificatePath)
|
||||
{
|
||||
_tlsConfiguration ??= new ClientTlsConfiguration();
|
||||
_tlsConfiguration.UseTls = true;
|
||||
_tlsConfiguration.ServerCaCertificatePath = certificatePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets a full TLS configuration.</summary>
|
||||
public LmxProxyClientBuilder WithTlsConfiguration(ClientTlsConfiguration config)
|
||||
{
|
||||
_tlsConfiguration = config ?? throw new ArgumentNullException(nameof(config));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Configures the retry policy. maxAttempts must be positive, retryDelay must be positive.</summary>
|
||||
public LmxProxyClientBuilder WithRetryPolicy(int maxAttempts, TimeSpan retryDelay)
|
||||
{
|
||||
if (maxAttempts <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxAttempts), "Max retry attempts must be greater than zero.");
|
||||
if (retryDelay <= TimeSpan.Zero)
|
||||
throw new ArgumentOutOfRangeException(nameof(retryDelay), "Retry delay must be greater than zero.");
|
||||
_maxRetryAttempts = maxAttempts;
|
||||
_retryDelay = retryDelay;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Enables client-side metrics collection.</summary>
|
||||
public LmxProxyClientBuilder WithMetrics()
|
||||
{
|
||||
_enableMetrics = true;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the correlation ID header name for request tracing.</summary>
|
||||
public LmxProxyClientBuilder WithCorrelationIdHeader(string headerName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(headerName))
|
||||
throw new ArgumentException("Header name must not be null or empty.", nameof(headerName));
|
||||
_correlationIdHeader = headerName;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds and returns a configured <see cref="LmxProxyClient"/> instance.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">Thrown when host is not set.</exception>
|
||||
/// <exception cref="FileNotFoundException">Thrown when TLS certificate paths don't exist.</exception>
|
||||
public LmxProxyClient Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_host))
|
||||
throw new InvalidOperationException("Host must be specified. Call WithHost() before Build().");
|
||||
|
||||
ValidateTlsConfiguration();
|
||||
|
||||
var client = new LmxProxyClient(_host, _port, _apiKey, _tlsConfiguration, _logger)
|
||||
{
|
||||
DefaultTimeout = _defaultTimeout
|
||||
};
|
||||
|
||||
client.SetBuilderConfiguration(new ClientConfiguration
|
||||
{
|
||||
MaxRetryAttempts = _maxRetryAttempts,
|
||||
RetryDelay = _retryDelay,
|
||||
EnableMetrics = _enableMetrics,
|
||||
CorrelationIdHeader = _correlationIdHeader
|
||||
});
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private void ValidateTlsConfiguration()
|
||||
{
|
||||
if (_tlsConfiguration?.UseTls != true)
|
||||
return;
|
||||
|
||||
if (!string.IsNullOrEmpty(_tlsConfiguration.ServerCaCertificatePath) &&
|
||||
!File.Exists(_tlsConfiguration.ServerCaCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"Server CA certificate not found: {_tlsConfiguration.ServerCaCertificatePath}",
|
||||
_tlsConfiguration.ServerCaCertificatePath);
|
||||
|
||||
if (!string.IsNullOrEmpty(_tlsConfiguration.ClientCertificatePath) &&
|
||||
!File.Exists(_tlsConfiguration.ClientCertificatePath))
|
||||
throw new FileNotFoundException(
|
||||
$"Client certificate not found: {_tlsConfiguration.ClientCertificatePath}",
|
||||
_tlsConfiguration.ClientCertificatePath);
|
||||
|
||||
if (!string.IsNullOrEmpty(_tlsConfiguration.ClientKeyPath) &&
|
||||
!File.Exists(_tlsConfiguration.ClientKeyPath))
|
||||
throw new FileNotFoundException(
|
||||
$"Client key not found: {_tlsConfiguration.ClientKeyPath}",
|
||||
_tlsConfiguration.ClientKeyPath);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for creating an LmxProxy client from IConfiguration sections.
|
||||
/// </summary>
|
||||
public class LmxProxyClientOptions
|
||||
{
|
||||
/// <summary>Host address of the LmxProxy server.</summary>
|
||||
public string Host { get; set; } = "localhost";
|
||||
|
||||
/// <summary>Port of the LmxProxy server.</summary>
|
||||
public int Port { get; set; } = 50051;
|
||||
|
||||
/// <summary>API key for authentication.</summary>
|
||||
public string? ApiKey { get; set; }
|
||||
|
||||
/// <summary>Default timeout for operations.</summary>
|
||||
public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>Whether to use TLS for the connection.</summary>
|
||||
public bool UseSsl { get; set; }
|
||||
|
||||
/// <summary>Path to the server CA certificate for TLS.</summary>
|
||||
public string? CertificatePath { get; set; }
|
||||
|
||||
/// <summary>Whether to enable client-side metrics collection.</summary>
|
||||
public bool EnableMetrics { get; set; }
|
||||
|
||||
/// <summary>Optional header name for correlation ID propagation.</summary>
|
||||
public string? CorrelationIdHeader { get; set; }
|
||||
|
||||
/// <summary>Retry policy options.</summary>
|
||||
public RetryOptions Retry { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retry policy configuration options.
|
||||
/// </summary>
|
||||
public class RetryOptions
|
||||
{
|
||||
/// <summary>Maximum number of retry attempts.</summary>
|
||||
public int MaxAttempts { get; set; } = 3;
|
||||
|
||||
/// <summary>Base delay between retries.</summary>
|
||||
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(1);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
/// Factory for creating configured gRPC channels with TLS support.
|
||||
/// </summary>
|
||||
internal static class GrpcChannelFactory
|
||||
{
|
||||
#pragma warning disable CA1810 // Initialize reference type static fields inline
|
||||
static GrpcChannelFactory()
|
||||
#pragma warning restore CA1810
|
||||
{
|
||||
// Enable HTTP/2 over plaintext for non-TLS scenarios
|
||||
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a <see cref="GrpcChannel"/> with the specified address, TLS configuration, and optional API key header.
|
||||
/// </summary>
|
||||
public static GrpcChannel CreateChannel(Uri address, ClientTlsConfiguration? tlsConfiguration, ILogger logger, string? apiKey = null)
|
||||
{
|
||||
var handler = new SocketsHttpHandler
|
||||
{
|
||||
EnableMultipleHttp2Connections = true
|
||||
};
|
||||
|
||||
if (tlsConfiguration?.UseTls == true)
|
||||
{
|
||||
ConfigureTls(handler, tlsConfiguration, logger);
|
||||
}
|
||||
|
||||
HttpMessageHandler finalHandler = handler;
|
||||
|
||||
// Add API key header to all outgoing requests if provided
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
finalHandler = new ApiKeyDelegatingHandler(apiKey, handler);
|
||||
}
|
||||
|
||||
var channelOptions = new GrpcChannelOptions
|
||||
{
|
||||
HttpHandler = finalHandler
|
||||
};
|
||||
|
||||
logger.LogDebug("Creating gRPC channel to {Address}, TLS={UseTls}", address, tlsConfiguration?.UseTls ?? false);
|
||||
return GrpcChannel.ForAddress(address, channelOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DelegatingHandler that adds the x-api-key header to all outgoing requests.
|
||||
/// </summary>
|
||||
private sealed class ApiKeyDelegatingHandler : DelegatingHandler
|
||||
{
|
||||
private readonly string _apiKey;
|
||||
|
||||
public ApiKeyDelegatingHandler(string apiKey, HttpMessageHandler innerHandler)
|
||||
: base(innerHandler)
|
||||
{
|
||||
_apiKey = apiKey;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("x-api-key", _apiKey);
|
||||
return base.SendAsync(request, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ConfigureTls(SocketsHttpHandler handler, ClientTlsConfiguration tls, ILogger logger)
|
||||
{
|
||||
handler.SslOptions = new SslClientAuthenticationOptions
|
||||
{
|
||||
EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(tls.ServerNameOverride))
|
||||
{
|
||||
handler.SslOptions.TargetHost = tls.ServerNameOverride;
|
||||
}
|
||||
|
||||
// Load client certificate for mTLS
|
||||
if (!string.IsNullOrEmpty(tls.ClientCertificatePath) && !string.IsNullOrEmpty(tls.ClientKeyPath))
|
||||
{
|
||||
var clientCert = X509Certificate2.CreateFromPemFile(tls.ClientCertificatePath, tls.ClientKeyPath);
|
||||
handler.SslOptions.ClientCertificates = [clientCert];
|
||||
logger.LogDebug("Loaded client certificate for mTLS from {Path}", tls.ClientCertificatePath);
|
||||
}
|
||||
|
||||
// Certificate validation callback
|
||||
handler.SslOptions.RemoteCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) =>
|
||||
{
|
||||
if (tls.IgnoreAllCertificateErrors)
|
||||
{
|
||||
logger.LogWarning("Ignoring all certificate errors (IgnoreAllCertificateErrors=true)");
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!tls.ValidateServerCertificate)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (sslPolicyErrors == SslPolicyErrors.None)
|
||||
return true;
|
||||
|
||||
// Custom CA trust store
|
||||
if (!string.IsNullOrEmpty(tls.ServerCaCertificatePath) && certificate is not null)
|
||||
{
|
||||
using var customChain = new X509Chain();
|
||||
customChain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
|
||||
customChain.ChainPolicy.CustomTrustStore.Add(X509CertificateLoader.LoadCertificateFromFile(tls.ServerCaCertificatePath));
|
||||
if (customChain.Build(new X509Certificate2(certificate)))
|
||||
return true;
|
||||
}
|
||||
|
||||
if (tls.AllowSelfSignedCertificates && sslPolicyErrors == SslPolicyErrors.RemoteCertificateChainErrors)
|
||||
{
|
||||
logger.LogWarning("Allowing self-signed certificate");
|
||||
return true;
|
||||
}
|
||||
|
||||
logger.LogError("Certificate validation failed: {Errors}", sslPolicyErrors);
|
||||
return false;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for registering LmxProxy client services in the DI container.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers a singleton ILmxProxyClient from the "LmxProxy" config section.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
return services.AddLmxProxyClient(configuration, "LmxProxy");
|
||||
}
|
||||
|
||||
/// <summary>Registers a singleton ILmxProxyClient from a named config section.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration, string sectionName)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClientFactory>(
|
||||
sp => new LmxProxyClientFactory(configuration));
|
||||
services.AddSingleton<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
|
||||
return factory.CreateClient(sectionName);
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a singleton ILmxProxyClient via builder action.</summary>
|
||||
public static IServiceCollection AddLmxProxyClient(
|
||||
this IServiceCollection services, Action<LmxProxyClientBuilder> configure)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a scoped ILmxProxyClient from the "LmxProxy" config section.</summary>
|
||||
public static IServiceCollection AddScopedLmxProxyClient(
|
||||
this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddSingleton<ILmxProxyClientFactory>(
|
||||
sp => new LmxProxyClientFactory(configuration));
|
||||
services.AddScoped<ILmxProxyClient>(sp =>
|
||||
{
|
||||
var factory = sp.GetRequiredService<ILmxProxyClientFactory>();
|
||||
return factory.CreateClient();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>Registers a keyed singleton ILmxProxyClient.</summary>
|
||||
public static IServiceCollection AddNamedLmxProxyClient(
|
||||
this IServiceCollection services, string name, Action<LmxProxyClientBuilder> configure)
|
||||
{
|
||||
services.AddKeyedSingleton<ILmxProxyClient>(name, (sp, key) =>
|
||||
{
|
||||
var builder = new LmxProxyClientBuilder();
|
||||
configure(builder);
|
||||
return builder.Build();
|
||||
});
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
using ZB.MOM.WW.LmxProxy.Client.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Client;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for streaming reads, writes, and subscriptions over ILmxProxyClient.
|
||||
/// </summary>
|
||||
public static class StreamingExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads multiple tags as an async stream in batches.
|
||||
/// Retries up to 2 times per batch. Aborts after 3 consecutive batch errors.
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
var batch = new List<string>(batchSize);
|
||||
int consecutiveErrors = 0;
|
||||
const int maxConsecutiveErrors = 3;
|
||||
const int maxRetries = 2;
|
||||
|
||||
foreach (string address in addresses)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
batch.Add(address);
|
||||
|
||||
if (batch.Count >= batchSize)
|
||||
{
|
||||
bool success = false;
|
||||
await foreach (var kvp in ReadBatchWithRetry(
|
||||
client, batch, maxRetries, cancellationToken))
|
||||
{
|
||||
consecutiveErrors = 0;
|
||||
success = true;
|
||||
yield return kvp;
|
||||
}
|
||||
if (!success)
|
||||
{
|
||||
consecutiveErrors++;
|
||||
if (consecutiveErrors >= maxConsecutiveErrors)
|
||||
yield break;
|
||||
}
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Process remaining
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await foreach (var kvp in ReadBatchWithRetry(
|
||||
client, batch, maxRetries, cancellationToken))
|
||||
{
|
||||
yield return kvp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static async IAsyncEnumerable<KeyValuePair<string, Vtq>> ReadBatchWithRetry(
|
||||
ILmxProxyClient client,
|
||||
List<string> batch,
|
||||
int maxRetries,
|
||||
[EnumeratorCancellation] CancellationToken ct)
|
||||
{
|
||||
int retries = 0;
|
||||
while (retries <= maxRetries)
|
||||
{
|
||||
IDictionary<string, Vtq>? results = null;
|
||||
try
|
||||
{
|
||||
results = await client.ReadBatchAsync(batch, ct);
|
||||
}
|
||||
catch when (retries < maxRetries)
|
||||
{
|
||||
retries++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (results is not null)
|
||||
{
|
||||
foreach (var kvp in results)
|
||||
yield return kvp;
|
||||
yield break;
|
||||
}
|
||||
retries++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes values from an async enumerable in batches. Returns total count written.
|
||||
/// </summary>
|
||||
public static async Task<int> WriteStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IAsyncEnumerable<KeyValuePair<string, TypedValue>> values,
|
||||
int batchSize = 100,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(values);
|
||||
if (batchSize <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(batchSize));
|
||||
|
||||
var batch = new Dictionary<string, TypedValue>(batchSize);
|
||||
int totalWritten = 0;
|
||||
|
||||
await foreach (var kvp in values.WithCancellation(cancellationToken))
|
||||
{
|
||||
batch[kvp.Key] = kvp.Value;
|
||||
|
||||
if (batch.Count >= batchSize)
|
||||
{
|
||||
await client.WriteBatchAsync(batch, cancellationToken);
|
||||
totalWritten += batch.Count;
|
||||
batch.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.Count > 0)
|
||||
{
|
||||
await client.WriteBatchAsync(batch, cancellationToken);
|
||||
totalWritten += batch.Count;
|
||||
}
|
||||
|
||||
return totalWritten;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes items in parallel with a configurable max concurrency (default 4).
|
||||
/// </summary>
|
||||
public static async Task ProcessInParallelAsync<T>(
|
||||
this IAsyncEnumerable<T> source,
|
||||
Func<T, CancellationToken, Task> processor,
|
||||
int maxConcurrency = 4,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(source);
|
||||
ArgumentNullException.ThrowIfNull(processor);
|
||||
if (maxConcurrency <= 0)
|
||||
throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
|
||||
|
||||
using var semaphore = new SemaphoreSlim(maxConcurrency);
|
||||
var tasks = new List<Task>();
|
||||
|
||||
await foreach (T item in source.WithCancellation(cancellationToken))
|
||||
{
|
||||
await semaphore.WaitAsync(cancellationToken);
|
||||
|
||||
tasks.Add(Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await processor(item, cancellationToken);
|
||||
}
|
||||
finally
|
||||
{
|
||||
semaphore.Release();
|
||||
}
|
||||
}, cancellationToken));
|
||||
}
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps a callback-based subscription into an IAsyncEnumerable via System.Threading.Channels.
|
||||
/// </summary>
|
||||
public static async IAsyncEnumerable<(string Tag, Vtq Vtq)> SubscribeStreamAsync(
|
||||
this ILmxProxyClient client,
|
||||
IEnumerable<string> addresses,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(client);
|
||||
ArgumentNullException.ThrowIfNull(addresses);
|
||||
|
||||
var channel = Channel.CreateBounded<(string, Vtq)>(
|
||||
new BoundedChannelOptions(1000)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
LmxProxyClient.ISubscription? subscription = null;
|
||||
try
|
||||
{
|
||||
subscription = await client.SubscribeAsync(
|
||||
addresses,
|
||||
(tag, vtq) =>
|
||||
{
|
||||
channel.Writer.TryWrite((tag, vtq));
|
||||
},
|
||||
ex =>
|
||||
{
|
||||
channel.Writer.TryComplete(ex);
|
||||
},
|
||||
cancellationToken);
|
||||
|
||||
await foreach (var item in channel.Reader.ReadAllAsync(cancellationToken))
|
||||
{
|
||||
yield return item;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
subscription?.Dispose();
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<LangVersion>latest</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<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 SCADA proxy service</Description>
|
||||
<PlatformTarget>AnyCPU</PlatformTarget>
|
||||
<Platforms>AnyCPU</Platforms>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.LmxProxy.Client.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<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>
|
||||
@@ -0,0 +1,104 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates the LmxProxy configuration at startup.
|
||||
/// Throws InvalidOperationException on any validation error.
|
||||
/// </summary>
|
||||
public static class ConfigurationValidator
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(ConfigurationValidator));
|
||||
|
||||
/// <summary>
|
||||
/// Validates all configuration settings and logs the effective values.
|
||||
/// Throws on first validation error.
|
||||
/// </summary>
|
||||
public static void ValidateAndLog(LmxProxyConfiguration config)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
// GrpcPort
|
||||
if (config.GrpcPort < 1 || config.GrpcPort > 65535)
|
||||
errors.Add($"GrpcPort must be 1-65535, got {config.GrpcPort}");
|
||||
|
||||
// Connection
|
||||
var conn = config.Connection;
|
||||
if (conn.MonitorIntervalSeconds <= 0)
|
||||
errors.Add($"Connection.MonitorIntervalSeconds must be > 0, got {conn.MonitorIntervalSeconds}");
|
||||
if (conn.ConnectionTimeoutSeconds <= 0)
|
||||
errors.Add($"Connection.ConnectionTimeoutSeconds must be > 0, got {conn.ConnectionTimeoutSeconds}");
|
||||
if (conn.ReadTimeoutSeconds <= 0)
|
||||
errors.Add($"Connection.ReadTimeoutSeconds must be > 0, got {conn.ReadTimeoutSeconds}");
|
||||
if (conn.WriteTimeoutSeconds <= 0)
|
||||
errors.Add($"Connection.WriteTimeoutSeconds must be > 0, got {conn.WriteTimeoutSeconds}");
|
||||
if (conn.MaxConcurrentOperations <= 0)
|
||||
errors.Add($"Connection.MaxConcurrentOperations must be > 0, got {conn.MaxConcurrentOperations}");
|
||||
if (conn.NodeName != null && conn.NodeName.Length > 255)
|
||||
errors.Add("Connection.NodeName must be <= 255 characters");
|
||||
if (conn.GalaxyName != null && conn.GalaxyName.Length > 255)
|
||||
errors.Add("Connection.GalaxyName must be <= 255 characters");
|
||||
|
||||
// Subscription
|
||||
var sub = config.Subscription;
|
||||
if (sub.ChannelCapacity < 0 || sub.ChannelCapacity > 100000)
|
||||
errors.Add($"Subscription.ChannelCapacity must be 0-100000, got {sub.ChannelCapacity}");
|
||||
var validModes = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{ "DropOldest", "DropNewest", "Wait" };
|
||||
if (!validModes.Contains(sub.ChannelFullMode))
|
||||
errors.Add($"Subscription.ChannelFullMode must be DropOldest, DropNewest, or Wait, got '{sub.ChannelFullMode}'");
|
||||
|
||||
// ServiceRecovery
|
||||
var sr = config.ServiceRecovery;
|
||||
if (sr.FirstFailureDelayMinutes < 0)
|
||||
errors.Add($"ServiceRecovery.FirstFailureDelayMinutes must be >= 0, got {sr.FirstFailureDelayMinutes}");
|
||||
if (sr.SecondFailureDelayMinutes < 0)
|
||||
errors.Add($"ServiceRecovery.SecondFailureDelayMinutes must be >= 0, got {sr.SecondFailureDelayMinutes}");
|
||||
if (sr.SubsequentFailureDelayMinutes < 0)
|
||||
errors.Add($"ServiceRecovery.SubsequentFailureDelayMinutes must be >= 0, got {sr.SubsequentFailureDelayMinutes}");
|
||||
if (sr.ResetPeriodDays <= 0)
|
||||
errors.Add($"ServiceRecovery.ResetPeriodDays must be > 0, got {sr.ResetPeriodDays}");
|
||||
|
||||
// TLS
|
||||
if (config.Tls.Enabled)
|
||||
{
|
||||
if (!File.Exists(config.Tls.ServerCertificatePath))
|
||||
Log.Warning("TLS enabled but server certificate not found at {Path} (will auto-generate)",
|
||||
config.Tls.ServerCertificatePath);
|
||||
if (!File.Exists(config.Tls.ServerKeyPath))
|
||||
Log.Warning("TLS enabled but server key not found at {Path} (will auto-generate)",
|
||||
config.Tls.ServerKeyPath);
|
||||
}
|
||||
|
||||
// WebServer
|
||||
if (config.WebServer.Enabled)
|
||||
{
|
||||
if (config.WebServer.Port < 1 || config.WebServer.Port > 65535)
|
||||
errors.Add($"WebServer.Port must be 1-65535, got {config.WebServer.Port}");
|
||||
}
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
foreach (var error in errors)
|
||||
Log.Error("Configuration error: {Error}", error);
|
||||
throw new InvalidOperationException(
|
||||
$"Configuration validation failed with {errors.Count} error(s): {string.Join("; ", errors)}");
|
||||
}
|
||||
|
||||
// Log effective configuration
|
||||
Log.Information("Configuration validated successfully");
|
||||
Log.Information(" GrpcPort: {Port}", config.GrpcPort);
|
||||
Log.Information(" ApiKeyConfigFile: {File}", config.ApiKeyConfigFile);
|
||||
Log.Information(" Connection.AutoReconnect: {AutoReconnect}", conn.AutoReconnect);
|
||||
Log.Information(" Connection.MonitorIntervalSeconds: {Interval}", conn.MonitorIntervalSeconds);
|
||||
Log.Information(" Connection.MaxConcurrentOperations: {Max}", conn.MaxConcurrentOperations);
|
||||
Log.Information(" Subscription.ChannelCapacity: {Capacity}", sub.ChannelCapacity);
|
||||
Log.Information(" Subscription.ChannelFullMode: {Mode}", sub.ChannelFullMode);
|
||||
Log.Information(" Tls.Enabled: {Enabled}", config.Tls.Enabled);
|
||||
Log.Information(" WebServer.Enabled: {Enabled}, Port: {Port}", config.WebServer.Enabled, config.WebServer.Port);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>MxAccess connection settings.</summary>
|
||||
public class ConnectionConfiguration
|
||||
{
|
||||
/// <summary>Auto-reconnect check interval in seconds. Default: 5.</summary>
|
||||
public int MonitorIntervalSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Initial connection timeout in seconds. Default: 30.</summary>
|
||||
public int ConnectionTimeoutSeconds { get; set; } = 30;
|
||||
|
||||
/// <summary>Per-read operation timeout in seconds. Default: 5.</summary>
|
||||
public int ReadTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Per-write operation timeout in seconds. Default: 5.</summary>
|
||||
public int WriteTimeoutSeconds { get; set; } = 5;
|
||||
|
||||
/// <summary>Semaphore limit for concurrent MxAccess operations. Default: 10.</summary>
|
||||
public int MaxConcurrentOperations { get; set; } = 10;
|
||||
|
||||
/// <summary>Enable auto-reconnect loop. Default: true.</summary>
|
||||
public bool AutoReconnect { get; set; } = true;
|
||||
|
||||
/// <summary>MxAccess node name (optional).</summary>
|
||||
public string? NodeName { get; set; }
|
||||
|
||||
/// <summary>MxAccess galaxy name (optional).</summary>
|
||||
public string? GalaxyName { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>Root configuration class bound to appsettings.json.</summary>
|
||||
public class LmxProxyConfiguration
|
||||
{
|
||||
/// <summary>gRPC server listen port. Default: 50051.</summary>
|
||||
public int GrpcPort { get; set; } = 50051;
|
||||
|
||||
/// <summary>Path to API key configuration file. Default: apikeys.json.</summary>
|
||||
public string ApiKeyConfigFile { get; set; } = "apikeys.json";
|
||||
|
||||
/// <summary>Unique client name for MxAccess Register(). Must be unique per instance. Default: auto-generated.</summary>
|
||||
public string? ClientName { get; set; }
|
||||
|
||||
/// <summary>MxAccess connection settings.</summary>
|
||||
public ConnectionConfiguration Connection { get; set; } = new ConnectionConfiguration();
|
||||
|
||||
/// <summary>Subscription channel settings.</summary>
|
||||
public SubscriptionConfiguration Subscription { get; set; } = new SubscriptionConfiguration();
|
||||
|
||||
/// <summary>TLS/SSL settings.</summary>
|
||||
public TlsConfiguration Tls { get; set; } = new TlsConfiguration();
|
||||
|
||||
/// <summary>Status web server settings.</summary>
|
||||
public WebServerConfiguration WebServer { get; set; } = new WebServerConfiguration();
|
||||
|
||||
/// <summary>Windows SCM service recovery settings.</summary>
|
||||
public ServiceRecoveryConfiguration ServiceRecovery { get; set; } = new ServiceRecoveryConfiguration();
|
||||
|
||||
/// <summary>Health check / active probe settings.</summary>
|
||||
public HealthCheckConfiguration HealthCheck { get; set; } = new HealthCheckConfiguration();
|
||||
}
|
||||
|
||||
/// <summary>Health check / probe configuration.</summary>
|
||||
public class HealthCheckConfiguration
|
||||
{
|
||||
/// <summary>Tag address to subscribe to for connection liveness. Default: DevPlatform.Scheduler.ScanTime.</summary>
|
||||
public string TestTagAddress { get; set; } = "DevPlatform.Scheduler.ScanTime";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum time (ms) without a value update on the test tag before forcing reconnect.
|
||||
/// Default: 5000 (5 seconds).
|
||||
/// </summary>
|
||||
public int ProbeStaleThresholdMs { get; set; } = 5000;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>Windows SCM service recovery settings.</summary>
|
||||
public class ServiceRecoveryConfiguration
|
||||
{
|
||||
/// <summary>Restart delay after first failure in minutes. Default: 1.</summary>
|
||||
public int FirstFailureDelayMinutes { get; set; } = 1;
|
||||
|
||||
/// <summary>Restart delay after second failure in minutes. Default: 5.</summary>
|
||||
public int SecondFailureDelayMinutes { get; set; } = 5;
|
||||
|
||||
/// <summary>Restart delay after subsequent failures in minutes. Default: 10.</summary>
|
||||
public int SubsequentFailureDelayMinutes { get; set; } = 10;
|
||||
|
||||
/// <summary>Days before failure count resets. Default: 1.</summary>
|
||||
public int ResetPeriodDays { get; set; } = 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>Subscription channel settings.</summary>
|
||||
public class SubscriptionConfiguration
|
||||
{
|
||||
/// <summary>Per-client subscription buffer size. Default: 1000.</summary>
|
||||
public int ChannelCapacity { get; set; } = 1000;
|
||||
|
||||
/// <summary>Backpressure strategy: DropOldest, DropNewest, or Wait. Default: DropOldest.</summary>
|
||||
public string ChannelFullMode { get; set; } = "DropOldest";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>TLS/SSL settings for the gRPC server.</summary>
|
||||
public class TlsConfiguration
|
||||
{
|
||||
/// <summary>Enable TLS on the gRPC server. Default: false.</summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
/// <summary>PEM server certificate path. Default: certs/server.crt.</summary>
|
||||
public string ServerCertificatePath { get; set; } = "certs/server.crt";
|
||||
|
||||
/// <summary>PEM server private key path. Default: certs/server.key.</summary>
|
||||
public string ServerKeyPath { get; set; } = "certs/server.key";
|
||||
|
||||
/// <summary>CA certificate for mutual TLS client validation. Default: certs/ca.crt.</summary>
|
||||
public string ClientCaCertificatePath { get; set; } = "certs/ca.crt";
|
||||
|
||||
/// <summary>Require client certificates (mutual TLS). Default: false.</summary>
|
||||
public bool RequireClientCertificate { get; set; } = false;
|
||||
|
||||
/// <summary>Check certificate revocation lists. Default: false.</summary>
|
||||
public bool CheckCertificateRevocation { get; set; } = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Configuration
|
||||
{
|
||||
/// <summary>HTTP status web server settings.</summary>
|
||||
public class WebServerConfiguration
|
||||
{
|
||||
/// <summary>Enable the status web server. Default: true.</summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>HTTP listen port. Default: 8080.</summary>
|
||||
public int Port { get; set; } = 8080;
|
||||
|
||||
/// <summary>Custom URL prefix (defaults to http://+:{Port}/ if null).</summary>
|
||||
public string? Prefix { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Represents the state of a SCADA client connection.
|
||||
/// </summary>
|
||||
public enum ConnectionState
|
||||
{
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Disconnecting,
|
||||
Error,
|
||||
Reconnecting
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Event arguments for SCADA client connection state changes.
|
||||
/// </summary>
|
||||
public class ConnectionStateChangedEventArgs : EventArgs
|
||||
{
|
||||
public ConnectionStateChangedEventArgs(ConnectionState previousState, ConnectionState currentState,
|
||||
string? message = null)
|
||||
{
|
||||
PreviousState = previousState;
|
||||
CurrentState = currentState;
|
||||
Timestamp = DateTime.UtcNow;
|
||||
Message = message;
|
||||
}
|
||||
|
||||
public ConnectionState PreviousState { get; }
|
||||
public ConnectionState CurrentState { get; }
|
||||
public DateTime Timestamp { get; }
|
||||
public string? Message { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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 (MxAccess wrapper).
|
||||
/// </summary>
|
||||
public interface IScadaClient : IAsyncDisposable
|
||||
{
|
||||
/// <summary>Gets whether the client is connected to MxAccess.</summary>
|
||||
bool IsConnected { get; }
|
||||
|
||||
/// <summary>Gets the current connection state.</summary>
|
||||
ConnectionState ConnectionState { get; }
|
||||
|
||||
/// <summary>Gets the UTC time when the current connection was established.</summary>
|
||||
DateTime ConnectedSince { get; }
|
||||
|
||||
/// <summary>Gets the number of times the client has reconnected since startup.</summary>
|
||||
int ReconnectCount { get; }
|
||||
|
||||
/// <summary>Occurs when the connection state changes.</summary>
|
||||
event EventHandler<ConnectionStateChangedEventArgs> ConnectionStateChanged;
|
||||
|
||||
/// <summary>Connects to MxAccess.</summary>
|
||||
Task ConnectAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Disconnects from MxAccess.</summary>
|
||||
Task DisconnectAsync(CancellationToken ct = default);
|
||||
|
||||
/// <summary>Reads a single tag value.</summary>
|
||||
/// <returns>VTQ with typed value.</returns>
|
||||
Task<Vtq> ReadAsync(string address, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Reads multiple tag values with semaphore-controlled concurrency.</summary>
|
||||
/// <returns>Dictionary of address to VTQ.</returns>
|
||||
Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(IEnumerable<string> addresses, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Writes a single tag value. Value is a native .NET type (not string).</summary>
|
||||
Task WriteAsync(string address, object value, CancellationToken ct = default);
|
||||
|
||||
/// <summary>Writes multiple tag values with semaphore-controlled concurrency.</summary>
|
||||
Task WriteBatchAsync(IReadOnlyDictionary<string, object> values, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch of values, then polls flagTag until it equals flagValue or timeout expires.
|
||||
/// Returns (writeSuccess, flagReached, elapsedMs).
|
||||
/// </summary>
|
||||
/// <param name="values">Tag-value pairs to write.</param>
|
||||
/// <param name="flagTag">Tag to poll after writes.</param>
|
||||
/// <param name="flagValue">Expected value (type-aware comparison).</param>
|
||||
/// <param name="timeoutMs">Max wait time in milliseconds.</param>
|
||||
/// <param name="pollIntervalMs">Poll interval in milliseconds.</param>
|
||||
/// <param name="ct">Cancellation token.</param>
|
||||
Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values,
|
||||
string flagTag,
|
||||
object flagValue,
|
||||
int timeoutMs,
|
||||
int pollIntervalMs,
|
||||
CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes specific tag addresses. Removes from stored subscriptions
|
||||
/// and COM state. Safe to call after reconnect -- uses current handle mappings.
|
||||
/// </summary>
|
||||
Task UnsubscribeByAddressAsync(IEnumerable<string> addresses);
|
||||
|
||||
/// <summary>Subscribes to value changes for specified addresses.</summary>
|
||||
/// <returns>Subscription handle for unsubscribing.</returns>
|
||||
Task<IAsyncDisposable> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> callback,
|
||||
CancellationToken ct = default);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps MxAccess MXSTATUS_PROXY fields (detail, category, source) to
|
||||
/// human-readable messages and OPC UA quality codes.
|
||||
/// </summary>
|
||||
public static class MxStatusMapper
|
||||
{
|
||||
// ── MxStatusDetail (short) → name + client message ──────────
|
||||
|
||||
private static readonly Dictionary<int, (string Name, string Message)> DetailCodes =
|
||||
new Dictionary<int, (string, string)>
|
||||
{
|
||||
{ 0, ("MX_S_Success", "Success") },
|
||||
{ 1, ("MX_E_RequestTimedOut", "Request to AVEVA System Platform timed out") },
|
||||
{ 2, ("MX_E_PlatformCommunicationError", "Communication error with System Platform") },
|
||||
{ 3, ("MX_E_InvalidPlatformId", "Invalid platform identifier") },
|
||||
{ 4, ("MX_E_InvalidEngineId", "Invalid engine identifier") },
|
||||
{ 5, ("MX_E_EngineCommunicationError", "Communication error with automation engine") },
|
||||
{ 6, ("MX_E_InvalidReference", "Tag reference is invalid or could not be resolved") },
|
||||
{ 7, ("MX_E_NoGalaxyRepository", "Galaxy repository is not available") },
|
||||
{ 8, ("MX_E_InvalidObjectId", "Invalid object identifier") },
|
||||
{ 9, ("MX_E_ObjectSignatureMismatch", "Object signature mismatch") },
|
||||
{ 10, ("MX_E_AttributeSignatureMismatch", "Attribute signature mismatch") },
|
||||
{ 11, ("MX_E_ResolvingAttribute", "Attribute is still being resolved") },
|
||||
{ 12, ("MX_E_ResolvingObject", "Object is still being resolved") },
|
||||
{ 13, ("MX_E_WrongDataType", "Value type does not match attribute data type") },
|
||||
{ 14, ("MX_E_WrongNumberOfDimensions", "Wrong number of array dimensions") },
|
||||
{ 15, ("MX_E_InvalidIndex", "Invalid array index") },
|
||||
{ 16, ("MX_E_IndexOutOfOrder", "Array index out of order") },
|
||||
{ 17, ("MX_E_DimensionDoesNotExist", "Array dimension does not exist") },
|
||||
{ 18, ("MX_E_ConversionNotSupported", "Data type conversion not supported") },
|
||||
{ 19, ("MX_E_UnableToConvertString", "Unable to convert string to target type") },
|
||||
{ 20, ("MX_E_Overflow", "Numeric overflow during conversion") },
|
||||
{ 21, ("MX_E_NmxVersionMismatch", "NMX version mismatch") },
|
||||
{ 22, ("MX_E_NmxInvalidCommand", "NMX invalid command") },
|
||||
{ 23, ("MX_E_LmxVersionMismatch", "LMX version mismatch") },
|
||||
{ 24, ("MX_E_LmxInvalidCommand", "LMX invalid command") },
|
||||
{ 25, ("MX_E_GalaxyRepositoryBusy", "Galaxy repository is busy") },
|
||||
{ 26, ("MX_E_EngineOverloaded", "Automation engine is overloaded") },
|
||||
{ 1000, ("MX_E_InvalidPrimitiveId", "Invalid primitive identifier") },
|
||||
{ 1001, ("MX_E_InvalidAttributeId", "Invalid attribute identifier") },
|
||||
{ 1002, ("MX_E_InvalidPropertyId", "Invalid property identifier") },
|
||||
{ 1003, ("MX_E_IndexOutOfRange", "Array index out of range") },
|
||||
{ 1004, ("MX_E_DataOutOfRange", "Data value out of range") },
|
||||
{ 1005, ("MX_E_IncorrectDataType", "Incorrect data type for this attribute") },
|
||||
{ 1006, ("MX_E_NotReadable", "Attribute is not readable") },
|
||||
{ 1007, ("MX_E_NotWriteable", "Attribute is not writable") },
|
||||
{ 1008, ("MX_E_WriteAccessDenied", "Write access denied — insufficient security") },
|
||||
{ 1009, ("MX_E_UnknownError", "Unknown MxAccess error") },
|
||||
{ 1010, ("MX_E_ObjectInitializing", "Object is still initializing") },
|
||||
{ 1011, ("MX_E_EngineInitializing", "Automation engine is still initializing") },
|
||||
{ 1012, ("MX_E_SecuredWrite", "Attribute requires secured write authentication") },
|
||||
{ 1013, ("MX_E_VerifiedWrite", "Attribute requires verified write (two-user)") },
|
||||
{ 1014, ("MX_E_NoAlarmAckPrivilege", "No alarm acknowledgment privilege") },
|
||||
{ 8000, ("MX_E_AutomationObjectSpecificError", "Automation object specific error") },
|
||||
};
|
||||
|
||||
// ── MxStatusCategory (int) → name ──────────
|
||||
|
||||
private static readonly Dictionary<int, string> CategoryNames = new Dictionary<int, string>
|
||||
{
|
||||
{ -1, "Unknown" },
|
||||
{ 0, "Ok" },
|
||||
{ 1, "Pending" },
|
||||
{ 2, "Warning" },
|
||||
{ 3, "CommunicationError" },
|
||||
{ 4, "ConfigurationError" },
|
||||
{ 5, "OperationalError" },
|
||||
{ 6, "SecurityError" },
|
||||
{ 7, "SoftwareError" },
|
||||
{ 8, "OtherError" },
|
||||
};
|
||||
|
||||
// ── MxStatusSource (int) → name ──────────
|
||||
|
||||
private static readonly Dictionary<int, string> SourceNames = new Dictionary<int, string>
|
||||
{
|
||||
{ -1, "Unknown" },
|
||||
{ 0, "RequestingLmx" },
|
||||
{ 1, "RespondingLmx" },
|
||||
{ 2, "RequestingNmx" },
|
||||
{ 3, "RespondingNmx" },
|
||||
{ 4, "RequestingAutomationObject" },
|
||||
{ 5, "RespondingAutomationObject" },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Gets the symbolic name for an MxStatusDetail code (e.g., "MX_E_WrongDataType").
|
||||
/// </summary>
|
||||
public static string GetDetailName(int detailCode)
|
||||
{
|
||||
return DetailCodes.TryGetValue(detailCode, out var entry) ? entry.Name : string.Format("MX_E_Unknown({0})", detailCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a human-readable client message for an MxStatusDetail code.
|
||||
/// </summary>
|
||||
public static string GetDetailMessage(int detailCode)
|
||||
{
|
||||
return DetailCodes.TryGetValue(detailCode, out var entry) ? entry.Message : string.Format("MxAccess error code {0}", detailCode);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the symbolic name for an MxStatusCategory value.
|
||||
/// </summary>
|
||||
public static string GetCategoryName(int category)
|
||||
{
|
||||
return CategoryNames.TryGetValue(category, out var name) ? name : string.Format("Unknown({0})", category);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the symbolic name for an MxStatusSource value.
|
||||
/// </summary>
|
||||
public static string GetSourceName(int source)
|
||||
{
|
||||
return SourceNames.TryGetValue(source, out var name) ? name : string.Format("Unknown({0})", source);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a detailed error string from all MXSTATUS_PROXY fields.
|
||||
/// Format: "MX_E_WrongDataType: Value type does not match attribute data type [Category=OperationalError, Source=RespondingAutomationObject]"
|
||||
/// </summary>
|
||||
public static string FormatStatus(int detail, int category, int source)
|
||||
{
|
||||
return string.Format("{0}: {1} [Category={2}, Source={3}]",
|
||||
GetDetailName(detail),
|
||||
GetDetailMessage(detail),
|
||||
GetCategoryName(category),
|
||||
GetSourceName(source));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an MxStatusCategory to the most appropriate OPC UA QualityCode.
|
||||
/// Used when MXSTATUS_PROXY.success is false in an OnDataChange callback
|
||||
/// to override the raw OPC DA quality byte.
|
||||
/// </summary>
|
||||
public static Quality CategoryToQuality(int category, int detail)
|
||||
{
|
||||
// Specific detail codes take priority
|
||||
switch (detail)
|
||||
{
|
||||
case 6: // MX_E_InvalidReference
|
||||
case 1001: // MX_E_InvalidAttributeId
|
||||
return Quality.Bad_ConfigError;
|
||||
case 2: // MX_E_PlatformCommunicationError
|
||||
case 5: // MX_E_EngineCommunicationError
|
||||
return Quality.Bad_CommFailure;
|
||||
case 11: // MX_E_ResolvingAttribute
|
||||
case 12: // MX_E_ResolvingObject
|
||||
case 1010: // MX_E_ObjectInitializing
|
||||
case 1011: // MX_E_EngineInitializing
|
||||
return Quality.Bad_WaitingForInitialData;
|
||||
case 1006: // MX_E_NotReadable
|
||||
return Quality.Bad_OutOfService;
|
||||
case 1: // MX_E_RequestTimedOut
|
||||
return Quality.Bad_CommFailure;
|
||||
}
|
||||
|
||||
// Fall back to category
|
||||
switch (category)
|
||||
{
|
||||
case 0: // MxCategoryOk
|
||||
return Quality.Good;
|
||||
case 1: // MxCategoryPending
|
||||
return Quality.Uncertain;
|
||||
case 2: // MxCategoryWarning
|
||||
return Quality.Uncertain;
|
||||
case 3: // MxCategoryCommunicationError
|
||||
return Quality.Bad_CommFailure;
|
||||
case 4: // MxCategoryConfigurationError
|
||||
return Quality.Bad_ConfigError;
|
||||
case 5: // MxCategoryOperationalError
|
||||
return Quality.Bad;
|
||||
case 6: // MxCategorySecurityError
|
||||
return Quality.Bad;
|
||||
case 7: // MxCategorySoftwareError
|
||||
return Quality.Bad;
|
||||
default:
|
||||
return Quality.Bad;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
public enum ProbeStatus
|
||||
{
|
||||
Healthy,
|
||||
TransportFailure,
|
||||
DataDegraded
|
||||
}
|
||||
|
||||
public sealed class ProbeResult
|
||||
{
|
||||
public ProbeStatus Status { get; }
|
||||
public Quality? Quality { get; }
|
||||
public DateTime? Timestamp { get; }
|
||||
public string? Message { get; }
|
||||
public Exception? Exception { get; }
|
||||
|
||||
private ProbeResult(ProbeStatus status, Quality? quality, DateTime? timestamp,
|
||||
string? message, Exception? exception)
|
||||
{
|
||||
Status = status;
|
||||
Quality = quality;
|
||||
Timestamp = timestamp;
|
||||
Message = message;
|
||||
Exception = exception;
|
||||
}
|
||||
|
||||
public static ProbeResult Healthy(Quality quality, DateTime timestamp)
|
||||
=> new ProbeResult(ProbeStatus.Healthy, quality, timestamp, null, null);
|
||||
|
||||
public static ProbeResult Degraded(Quality quality, DateTime timestamp, string message)
|
||||
=> new ProbeResult(ProbeStatus.DataDegraded, quality, timestamp, message, null);
|
||||
|
||||
public static ProbeResult TransportFailed(string message, Exception? ex = null)
|
||||
=> new ProbeResult(ProbeStatus.TransportFailure, null, null, message, ex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
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 DA quality code,
|
||||
/// enabling direct round-trip between the domain enum and the wire OPC DA byte.
|
||||
/// </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,
|
||||
|
||||
/// <summary>0x20 - Bad [Waiting for Initial Data]</summary>
|
||||
Bad_WaitingForInitialData = 32,
|
||||
|
||||
// ──────────── 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,167 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Maps between the domain <see cref="Quality"/> enum and proto QualityCode messages.
|
||||
/// status_code (uint32) is canonical. symbolic_name is derived from a lookup table.
|
||||
/// </summary>
|
||||
public static class QualityCodeMapper
|
||||
{
|
||||
/// <summary>OPC UA status code → symbolic name lookup.</summary>
|
||||
private static readonly Dictionary<uint, string> StatusCodeToName = new Dictionary<uint, string>
|
||||
{
|
||||
// Good
|
||||
{ 0x00000000, "Good" },
|
||||
{ 0x00D80000, "GoodLocalOverride" },
|
||||
|
||||
// Uncertain
|
||||
{ 0x40900000, "UncertainLastUsableValue" },
|
||||
{ 0x42390000, "UncertainSensorNotAccurate" },
|
||||
{ 0x40540000, "UncertainEngineeringUnitsExceeded" },
|
||||
{ 0x40580000, "UncertainSubNormal" },
|
||||
|
||||
// Bad
|
||||
{ 0x80000000, "Bad" },
|
||||
{ 0x80040000, "BadConfigurationError" },
|
||||
{ 0x808A0000, "BadNotConnected" },
|
||||
{ 0x806B0000, "BadDeviceFailure" },
|
||||
{ 0x806D0000, "BadSensorFailure" },
|
||||
{ 0x80050000, "BadCommunicationFailure" },
|
||||
{ 0x808F0000, "BadOutOfService" },
|
||||
{ 0x80320000, "BadWaitingForInitialData" },
|
||||
};
|
||||
|
||||
/// <summary>Domain Quality enum → OPC UA status code.</summary>
|
||||
private static readonly Dictionary<Quality, uint> QualityToStatusCode = new Dictionary<Quality, uint>
|
||||
{
|
||||
// Good family
|
||||
{ Quality.Good, 0x00000000 },
|
||||
{ Quality.Good_LowLimited, 0x00000000 },
|
||||
{ Quality.Good_HighLimited, 0x00000000 },
|
||||
{ Quality.Good_Constant, 0x00000000 },
|
||||
{ Quality.Good_LocalOverride, 0x00D80000 },
|
||||
{ Quality.Good_LocalOverride_LL, 0x00D80000 },
|
||||
{ Quality.Good_LocalOverride_HL, 0x00D80000 },
|
||||
{ Quality.Good_LocalOverride_C, 0x00D80000 },
|
||||
|
||||
// Uncertain family
|
||||
{ Quality.Uncertain, 0x40900000 },
|
||||
{ Quality.Uncertain_LowLimited, 0x40900000 },
|
||||
{ Quality.Uncertain_HighLimited, 0x40900000 },
|
||||
{ Quality.Uncertain_Constant, 0x40900000 },
|
||||
{ Quality.Uncertain_LastUsable, 0x40900000 },
|
||||
{ Quality.Uncertain_LastUsable_LL, 0x40900000 },
|
||||
{ Quality.Uncertain_LastUsable_HL, 0x40900000 },
|
||||
{ Quality.Uncertain_LastUsable_Cnst, 0x40900000 },
|
||||
{ Quality.Uncertain_SensorNotAcc, 0x42390000 },
|
||||
{ Quality.Uncertain_SensorNotAcc_LL, 0x42390000 },
|
||||
{ Quality.Uncertain_SensorNotAcc_HL, 0x42390000 },
|
||||
{ Quality.Uncertain_SensorNotAcc_C, 0x42390000 },
|
||||
{ Quality.Uncertain_EuExceeded, 0x40540000 },
|
||||
{ Quality.Uncertain_EuExceeded_LL, 0x40540000 },
|
||||
{ Quality.Uncertain_EuExceeded_HL, 0x40540000 },
|
||||
{ Quality.Uncertain_EuExceeded_C, 0x40540000 },
|
||||
{ Quality.Uncertain_SubNormal, 0x40580000 },
|
||||
{ Quality.Uncertain_SubNormal_LL, 0x40580000 },
|
||||
{ Quality.Uncertain_SubNormal_HL, 0x40580000 },
|
||||
{ Quality.Uncertain_SubNormal_C, 0x40580000 },
|
||||
|
||||
// Bad family
|
||||
{ Quality.Bad, 0x80000000 },
|
||||
{ Quality.Unknown, 0x80000000 },
|
||||
{ Quality.Bad_ConfigError, 0x80040000 },
|
||||
{ Quality.Bad_NotConnected, 0x808A0000 },
|
||||
{ Quality.Bad_DeviceFailure, 0x806B0000 },
|
||||
{ Quality.Bad_SensorFailure, 0x806D0000 },
|
||||
{ Quality.Bad_LastKnownValue, 0x80050000 },
|
||||
{ Quality.Bad_CommFailure, 0x80050000 },
|
||||
{ Quality.Bad_OutOfService, 0x808F0000 },
|
||||
{ Quality.Bad_WaitingForInitialData, 0x80320000 },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts a domain Quality enum to a proto QualityCode message.
|
||||
/// </summary>
|
||||
public static Scada.QualityCode ToQualityCode(Quality quality)
|
||||
{
|
||||
var statusCode = QualityToStatusCode.TryGetValue(quality, out var code) ? code : 0x80000000u;
|
||||
var symbolicName = StatusCodeToName.TryGetValue(statusCode, out var name) ? name : "Bad";
|
||||
|
||||
return new Scada.QualityCode
|
||||
{
|
||||
StatusCode = statusCode,
|
||||
SymbolicName = symbolicName
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>OPC UA status code → primary domain Quality (reverse lookup).</summary>
|
||||
private static readonly Dictionary<uint, Quality> StatusCodeToQuality = new Dictionary<uint, Quality>
|
||||
{
|
||||
// Good
|
||||
{ 0x00000000, Quality.Good },
|
||||
{ 0x00D80000, Quality.Good_LocalOverride },
|
||||
|
||||
// Uncertain — pick the most specific base variant
|
||||
{ 0x40900000, Quality.Uncertain_LastUsable },
|
||||
{ 0x42390000, Quality.Uncertain_SensorNotAcc },
|
||||
{ 0x40540000, Quality.Uncertain_EuExceeded },
|
||||
{ 0x40580000, Quality.Uncertain_SubNormal },
|
||||
|
||||
// Bad
|
||||
{ 0x80000000, Quality.Bad },
|
||||
{ 0x80040000, Quality.Bad_ConfigError },
|
||||
{ 0x808A0000, Quality.Bad_NotConnected },
|
||||
{ 0x806B0000, Quality.Bad_DeviceFailure },
|
||||
{ 0x806D0000, Quality.Bad_SensorFailure },
|
||||
{ 0x80050000, Quality.Bad_CommFailure },
|
||||
{ 0x808F0000, Quality.Bad_OutOfService },
|
||||
{ 0x80320000, Quality.Bad_WaitingForInitialData },
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Converts an OPC UA status code (uint32) to a domain Quality enum.
|
||||
/// Falls back to the nearest category if the specific code is not mapped.
|
||||
/// </summary>
|
||||
public static Quality FromStatusCode(uint statusCode)
|
||||
{
|
||||
if (StatusCodeToQuality.TryGetValue(statusCode, out var quality))
|
||||
return quality;
|
||||
|
||||
// Category fallback
|
||||
uint category = statusCode & 0xC0000000;
|
||||
if (category == 0x00000000) return Quality.Good;
|
||||
if (category == 0x40000000) return Quality.Uncertain;
|
||||
return Quality.Bad;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the symbolic name for a status code.
|
||||
/// </summary>
|
||||
public static string GetSymbolicName(uint statusCode)
|
||||
{
|
||||
if (StatusCodeToName.TryGetValue(statusCode, out var name))
|
||||
return name;
|
||||
|
||||
uint category = statusCode & 0xC0000000;
|
||||
if (category == 0x00000000) return "Good";
|
||||
if (category == 0x40000000) return "Uncertain";
|
||||
return "Bad";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a QualityCode for a specific well-known status.
|
||||
/// </summary>
|
||||
public static Scada.QualityCode Good() => new Scada.QualityCode { StatusCode = 0x00000000, SymbolicName = "Good" };
|
||||
public static Scada.QualityCode Bad() => new Scada.QualityCode { StatusCode = 0x80000000, SymbolicName = "Bad" };
|
||||
public static Scada.QualityCode BadConfigurationError() => new Scada.QualityCode { StatusCode = 0x80040000, SymbolicName = "BadConfigurationError" };
|
||||
public static Scada.QualityCode BadCommunicationFailure() => new Scada.QualityCode { StatusCode = 0x80050000, SymbolicName = "BadCommunicationFailure" };
|
||||
public static Scada.QualityCode BadNotConnected() => new Scada.QualityCode { StatusCode = 0x808A0000, SymbolicName = "BadNotConnected" };
|
||||
public static Scada.QualityCode BadDeviceFailure() => new Scada.QualityCode { StatusCode = 0x806B0000, SymbolicName = "BadDeviceFailure" };
|
||||
public static Scada.QualityCode BadSensorFailure() => new Scada.QualityCode { StatusCode = 0x806D0000, SymbolicName = "BadSensorFailure" };
|
||||
public static Scada.QualityCode BadOutOfService() => new Scada.QualityCode { StatusCode = 0x808F0000, SymbolicName = "BadOutOfService" };
|
||||
public static Scada.QualityCode BadWaitingForInitialData() => new Scada.QualityCode { StatusCode = 0x80320000, SymbolicName = "BadWaitingForInitialData" };
|
||||
public static Scada.QualityCode GoodLocalOverride() => new Scada.QualityCode { StatusCode = 0x00D80000, SymbolicName = "GoodLocalOverride" };
|
||||
public static Scada.QualityCode UncertainLastUsableValue() => new Scada.QualityCode { StatusCode = 0x40900000, SymbolicName = "UncertainLastUsableValue" };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for the <see cref="Quality"/> enum.
|
||||
/// </summary>
|
||||
public static class QualityExtensions
|
||||
{
|
||||
/// <summary>Returns true if quality is in the Good family (byte >= 192).</summary>
|
||||
public static bool IsGood(this Quality q) => (byte)q >= 192;
|
||||
|
||||
/// <summary>Returns true if quality is in the Uncertain family (byte 64-127).</summary>
|
||||
public static bool IsUncertain(this Quality q) => (byte)q >= 64 && (byte)q < 128;
|
||||
|
||||
/// <summary>Returns true if quality is in the Bad family (byte < 64).</summary>
|
||||
public static bool IsBad(this Quality q) => (byte)q < 64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>Subscription statistics for monitoring.</summary>
|
||||
public class SubscriptionStats
|
||||
{
|
||||
public SubscriptionStats(int totalClients, int totalTags, int activeSubscriptions,
|
||||
long totalDelivered = 0, long totalDropped = 0)
|
||||
{
|
||||
TotalClients = totalClients;
|
||||
TotalTags = totalTags;
|
||||
ActiveSubscriptions = activeSubscriptions;
|
||||
TotalDelivered = totalDelivered;
|
||||
TotalDropped = totalDropped;
|
||||
}
|
||||
|
||||
public int TotalClients { get; }
|
||||
public int TotalTags { get; }
|
||||
public int ActiveSubscriptions { get; }
|
||||
public long TotalDelivered { get; }
|
||||
public long TotalDropped { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Type-aware equality comparison for WriteBatchAndWait flag matching.
|
||||
/// </summary>
|
||||
public static class TypedValueComparer
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns true if both values are the same type and equal.
|
||||
/// Mismatched types are never equal.
|
||||
/// Null equals null only.
|
||||
/// </summary>
|
||||
public new static bool Equals(object? a, object? b)
|
||||
{
|
||||
if (a == null && b == null) return true;
|
||||
if (a == null || b == null) return false;
|
||||
if (a.GetType() != b.GetType()) return false;
|
||||
|
||||
if (a is Array arrA && b is Array arrB)
|
||||
{
|
||||
if (arrA.Length != arrB.Length) return false;
|
||||
for (int i = 0; i < arrA.Length; i++)
|
||||
{
|
||||
if (!object.Equals(arrA.GetValue(i), arrB.GetValue(i)))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return object.Equals(a, b);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
using System;
|
||||
using Google.Protobuf;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Domain
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts between COM variant objects (boxed .NET types from MxAccess)
|
||||
/// and proto-generated <see cref="Scada.TypedValue"/> messages.
|
||||
/// </summary>
|
||||
public static class TypedValueConverter
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TypedValueConverter));
|
||||
|
||||
/// <summary>
|
||||
/// Converts a COM variant object to a proto TypedValue.
|
||||
/// Returns null (unset TypedValue) for null, DBNull, or VT_EMPTY/VT_NULL.
|
||||
/// </summary>
|
||||
public static Scada.TypedValue? ToTypedValue(object? value)
|
||||
{
|
||||
if (value == null || value is DBNull)
|
||||
return null;
|
||||
|
||||
switch (value)
|
||||
{
|
||||
case bool b:
|
||||
return new Scada.TypedValue { BoolValue = b };
|
||||
|
||||
case short s: // VT_I2 → widened to int32
|
||||
return new Scada.TypedValue { Int32Value = s };
|
||||
|
||||
case int i: // VT_I4
|
||||
return new Scada.TypedValue { Int32Value = i };
|
||||
|
||||
case long l: // VT_I8
|
||||
return new Scada.TypedValue { Int64Value = l };
|
||||
|
||||
case ushort us: // VT_UI2 → widened to int32
|
||||
return new Scada.TypedValue { Int32Value = us };
|
||||
|
||||
case uint ui: // VT_UI4 → widened to int64 to avoid sign issues
|
||||
return new Scada.TypedValue { Int64Value = ui };
|
||||
|
||||
case ulong ul: // VT_UI8 → int64, truncation risk
|
||||
if (ul > (ulong)long.MaxValue)
|
||||
Log.Warning("ulong value {Value} exceeds long.MaxValue, truncation will occur", ul);
|
||||
return new Scada.TypedValue { Int64Value = (long)ul };
|
||||
|
||||
case float f: // VT_R4
|
||||
return new Scada.TypedValue { FloatValue = f };
|
||||
|
||||
case double d: // VT_R8
|
||||
return new Scada.TypedValue { DoubleValue = d };
|
||||
|
||||
case string str: // VT_BSTR
|
||||
return new Scada.TypedValue { StringValue = str };
|
||||
|
||||
case DateTime dt: // VT_DATE → UTC Ticks
|
||||
return new Scada.TypedValue { DatetimeValue = dt.ToUniversalTime().Ticks };
|
||||
|
||||
case decimal dec: // VT_DECIMAL → double (precision loss)
|
||||
Log.Warning("Decimal value {Value} converted to double, precision loss may occur", dec);
|
||||
return new Scada.TypedValue { DoubleValue = (double)dec };
|
||||
|
||||
case byte[] bytes: // VT_ARRAY of bytes
|
||||
return new Scada.TypedValue { BytesValue = ByteString.CopyFrom(bytes) };
|
||||
|
||||
case bool[] boolArr:
|
||||
{
|
||||
var arr = new Scada.BoolArray();
|
||||
arr.Values.AddRange(boolArr);
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { BoolValues = arr } };
|
||||
}
|
||||
|
||||
case int[] intArr:
|
||||
{
|
||||
var arr = new Scada.Int32Array();
|
||||
arr.Values.AddRange(intArr);
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int32Values = arr } };
|
||||
}
|
||||
|
||||
case long[] longArr:
|
||||
{
|
||||
var arr = new Scada.Int64Array();
|
||||
arr.Values.AddRange(longArr);
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { Int64Values = arr } };
|
||||
}
|
||||
|
||||
case float[] floatArr:
|
||||
{
|
||||
var arr = new Scada.FloatArray();
|
||||
arr.Values.AddRange(floatArr);
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { FloatValues = arr } };
|
||||
}
|
||||
|
||||
case double[] doubleArr:
|
||||
{
|
||||
var arr = new Scada.DoubleArray();
|
||||
arr.Values.AddRange(doubleArr);
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DoubleValues = arr } };
|
||||
}
|
||||
|
||||
case string[] strArr:
|
||||
{
|
||||
var arr = new Scada.StringArray();
|
||||
arr.Values.AddRange(strArr);
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { StringValues = arr } };
|
||||
}
|
||||
|
||||
case DateTime[] dtArr:
|
||||
{
|
||||
var arr = new Scada.DatetimeArray();
|
||||
arr.Values.AddRange(Array.ConvertAll(dtArr, dt => dt.ToUniversalTime().Ticks));
|
||||
return new Scada.TypedValue { ArrayValue = new Scada.ArrayValue { DatetimeValues = arr } };
|
||||
}
|
||||
|
||||
default:
|
||||
// VT_UNKNOWN or any unrecognized type — ToString() fallback
|
||||
Log.Warning("Unrecognized COM variant type {Type}, using ToString() fallback", value.GetType().Name);
|
||||
return new Scada.TypedValue { StringValue = value.ToString() };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a proto TypedValue back to a boxed .NET object.
|
||||
/// Returns null for unset oneof (null TypedValue or ValueCase.None).
|
||||
/// </summary>
|
||||
public static object? FromTypedValue(Scada.TypedValue? typedValue)
|
||||
{
|
||||
if (typedValue == null)
|
||||
return null;
|
||||
|
||||
switch (typedValue.ValueCase)
|
||||
{
|
||||
case Scada.TypedValue.ValueOneofCase.BoolValue:
|
||||
return typedValue.BoolValue;
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.Int32Value:
|
||||
return typedValue.Int32Value;
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.Int64Value:
|
||||
return typedValue.Int64Value;
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.FloatValue:
|
||||
return typedValue.FloatValue;
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.DoubleValue:
|
||||
return typedValue.DoubleValue;
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.StringValue:
|
||||
return typedValue.StringValue;
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.BytesValue:
|
||||
return typedValue.BytesValue.ToByteArray();
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.DatetimeValue:
|
||||
return new DateTime(typedValue.DatetimeValue, DateTimeKind.Utc);
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.ArrayValue:
|
||||
return FromArrayValue(typedValue.ArrayValue);
|
||||
|
||||
case Scada.TypedValue.ValueOneofCase.None:
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static object? FromArrayValue(Scada.ArrayValue? arrayValue)
|
||||
{
|
||||
if (arrayValue == null)
|
||||
return null;
|
||||
|
||||
switch (arrayValue.ValuesCase)
|
||||
{
|
||||
case Scada.ArrayValue.ValuesOneofCase.BoolValues:
|
||||
return arrayValue.BoolValues?.Values?.Count > 0
|
||||
? ToArray(arrayValue.BoolValues.Values)
|
||||
: Array.Empty<bool>();
|
||||
|
||||
case Scada.ArrayValue.ValuesOneofCase.Int32Values:
|
||||
return arrayValue.Int32Values?.Values?.Count > 0
|
||||
? ToArray(arrayValue.Int32Values.Values)
|
||||
: Array.Empty<int>();
|
||||
|
||||
case Scada.ArrayValue.ValuesOneofCase.Int64Values:
|
||||
return arrayValue.Int64Values?.Values?.Count > 0
|
||||
? ToArray(arrayValue.Int64Values.Values)
|
||||
: Array.Empty<long>();
|
||||
|
||||
case Scada.ArrayValue.ValuesOneofCase.FloatValues:
|
||||
return arrayValue.FloatValues?.Values?.Count > 0
|
||||
? ToArray(arrayValue.FloatValues.Values)
|
||||
: Array.Empty<float>();
|
||||
|
||||
case Scada.ArrayValue.ValuesOneofCase.DoubleValues:
|
||||
return arrayValue.DoubleValues?.Values?.Count > 0
|
||||
? ToArray(arrayValue.DoubleValues.Values)
|
||||
: Array.Empty<double>();
|
||||
|
||||
case Scada.ArrayValue.ValuesOneofCase.StringValues:
|
||||
return arrayValue.StringValues?.Values?.Count > 0
|
||||
? ToArray(arrayValue.StringValues.Values)
|
||||
: Array.Empty<string>();
|
||||
|
||||
case Scada.ArrayValue.ValuesOneofCase.DatetimeValues:
|
||||
if (arrayValue.DatetimeValues?.Values?.Count > 0)
|
||||
{
|
||||
var ticks = ToArray(arrayValue.DatetimeValues.Values);
|
||||
var result = new DateTime[ticks.Length];
|
||||
for (int i = 0; i < ticks.Length; i++)
|
||||
result[i] = new DateTime(ticks[i], DateTimeKind.Utc);
|
||||
return result;
|
||||
}
|
||||
return Array.Empty<DateTime>();
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static T[] ToArray<T>(Google.Protobuf.Collections.RepeatedField<T> repeatedField)
|
||||
{
|
||||
var result = new T[repeatedField.Count];
|
||||
for (int i = 0; i < repeatedField.Count; i++)
|
||||
result[i] = repeatedField[i];
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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. Null represents an unset/missing value.</summary>
|
||||
public object? Value { get; }
|
||||
|
||||
/// <summary>Gets the UTC timestamp when the value was read.</summary>
|
||||
public DateTime Timestamp { get; }
|
||||
|
||||
/// <summary>Gets the quality of the value.</summary>
|
||||
public Quality Quality { get; }
|
||||
|
||||
public Vtq(object? value, DateTime timestamp, Quality quality)
|
||||
{
|
||||
Value = value;
|
||||
Timestamp = timestamp;
|
||||
Quality = quality;
|
||||
}
|
||||
|
||||
public static Vtq New(object? value, Quality quality) => new(value, DateTime.UtcNow, quality);
|
||||
public static Vtq New(object? value, DateTime timestamp, Quality quality) => new(value, timestamp, quality);
|
||||
public static Vtq Good(object value) => new(value, DateTime.UtcNow, Quality.Good);
|
||||
public static Vtq Bad(object? value = null) => new(value, DateTime.UtcNow, Quality.Bad);
|
||||
public static Vtq Uncertain(object value) => new(value, DateTime.UtcNow, Quality.Uncertain);
|
||||
|
||||
public bool Equals(Vtq other) =>
|
||||
Equals(Value, other.Value) && Timestamp.Equals(other.Timestamp) && Quality == other.Quality;
|
||||
|
||||
public override bool Equals(object obj) => obj is Vtq other && Equals(other);
|
||||
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hashCode = Value != null ? Value.GetHashCode() : 0;
|
||||
hashCode = (hashCode * 397) ^ Timestamp.GetHashCode();
|
||||
hashCode = (hashCode * 397) ^ (int)Quality;
|
||||
return hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
public override string ToString() =>
|
||||
$"{{Value={Value}, Timestamp={Timestamp:yyyy-MM-dd HH:mm:ss.fff}, Quality={Quality}}}";
|
||||
|
||||
public static bool operator ==(Vtq left, Vtq right) => left.Equals(right);
|
||||
public static bool operator !=(Vtq left, Vtq right) => !left.Equals(right);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
syntax = "proto3";
|
||||
package scada;
|
||||
|
||||
// ============================================================
|
||||
// Service Definition
|
||||
// ============================================================
|
||||
|
||||
service ScadaService {
|
||||
rpc Connect(ConnectRequest) returns (ConnectResponse);
|
||||
rpc Disconnect(DisconnectRequest) returns (DisconnectResponse);
|
||||
rpc GetConnectionState(GetConnectionStateRequest) returns (GetConnectionStateResponse);
|
||||
rpc Read(ReadRequest) returns (ReadResponse);
|
||||
rpc ReadBatch(ReadBatchRequest) returns (ReadBatchResponse);
|
||||
rpc Write(WriteRequest) returns (WriteResponse);
|
||||
rpc WriteBatch(WriteBatchRequest) returns (WriteBatchResponse);
|
||||
rpc WriteBatchAndWait(WriteBatchAndWaitRequest) returns (WriteBatchAndWaitResponse);
|
||||
rpc Subscribe(SubscribeRequest) returns (stream VtqMessage);
|
||||
rpc CheckApiKey(CheckApiKeyRequest) returns (CheckApiKeyResponse);
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Typed Value System
|
||||
// ============================================================
|
||||
|
||||
message TypedValue {
|
||||
oneof value {
|
||||
bool bool_value = 1;
|
||||
int32 int32_value = 2;
|
||||
int64 int64_value = 3;
|
||||
float float_value = 4;
|
||||
double double_value = 5;
|
||||
string string_value = 6;
|
||||
bytes bytes_value = 7;
|
||||
int64 datetime_value = 8; // UTC DateTime.Ticks (100ns intervals since 0001-01-01)
|
||||
ArrayValue array_value = 9;
|
||||
}
|
||||
}
|
||||
|
||||
message ArrayValue {
|
||||
oneof values {
|
||||
BoolArray bool_values = 1;
|
||||
Int32Array int32_values = 2;
|
||||
Int64Array int64_values = 3;
|
||||
FloatArray float_values = 4;
|
||||
DoubleArray double_values = 5;
|
||||
StringArray string_values = 6;
|
||||
DatetimeArray datetime_values = 7; // UTC DateTime.Ticks arrays
|
||||
}
|
||||
}
|
||||
|
||||
message BoolArray { repeated bool values = 1; }
|
||||
message Int32Array { repeated int32 values = 1; }
|
||||
message Int64Array { repeated int64 values = 1; }
|
||||
message FloatArray { repeated float values = 1; }
|
||||
message DoubleArray { repeated double values = 1; }
|
||||
message StringArray { repeated string values = 1; }
|
||||
message DatetimeArray { repeated int64 values = 1; } // UTC DateTime.Ticks
|
||||
|
||||
// ============================================================
|
||||
// OPC UA-Style Quality Codes
|
||||
// ============================================================
|
||||
|
||||
message QualityCode {
|
||||
uint32 status_code = 1;
|
||||
string symbolic_name = 2;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Connection Lifecycle
|
||||
// ============================================================
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
message CheckApiKeyRequest {
|
||||
string api_key = 1;
|
||||
}
|
||||
|
||||
message CheckApiKeyResponse {
|
||||
bool is_valid = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Value-Timestamp-Quality
|
||||
// ============================================================
|
||||
|
||||
message VtqMessage {
|
||||
string tag = 1;
|
||||
TypedValue value = 2;
|
||||
int64 timestamp_utc_ticks = 3;
|
||||
QualityCode quality = 4;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Read Operations
|
||||
// ============================================================
|
||||
|
||||
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 Operations
|
||||
// ============================================================
|
||||
|
||||
message WriteRequest {
|
||||
string session_id = 1;
|
||||
string tag = 2;
|
||||
TypedValue value = 3;
|
||||
}
|
||||
|
||||
message WriteResponse {
|
||||
bool success = 1;
|
||||
string message = 2;
|
||||
}
|
||||
|
||||
message WriteItem {
|
||||
string tag = 1;
|
||||
TypedValue 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;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// WriteBatchAndWait
|
||||
// ============================================================
|
||||
|
||||
message WriteBatchAndWaitRequest {
|
||||
string session_id = 1;
|
||||
repeated WriteItem items = 2;
|
||||
string flag_tag = 3;
|
||||
TypedValue 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
|
||||
// ============================================================
|
||||
|
||||
message SubscribeRequest {
|
||||
string session_id = 1;
|
||||
repeated string tags = 2;
|
||||
int32 sampling_ms = 3;
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using GrpcStatus = Grpc.Core.Status;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Sessions;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Security;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Grpc.Services
|
||||
{
|
||||
/// <summary>
|
||||
/// gRPC service implementation for all 10 SCADA RPCs.
|
||||
/// Inherits from proto-generated ScadaService.ScadaServiceBase.
|
||||
/// </summary>
|
||||
public class ScadaGrpcService : Scada.ScadaService.ScadaServiceBase
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<ScadaGrpcService>();
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SessionManager _sessionManager;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
private readonly PerformanceMetrics? _performanceMetrics;
|
||||
private readonly ApiKeyService? _apiKeyService;
|
||||
|
||||
public ScadaGrpcService(
|
||||
IScadaClient scadaClient,
|
||||
SessionManager sessionManager,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics? performanceMetrics = null,
|
||||
ApiKeyService? apiKeyService = null)
|
||||
{
|
||||
_scadaClient = scadaClient;
|
||||
_sessionManager = sessionManager;
|
||||
_subscriptionManager = subscriptionManager;
|
||||
_performanceMetrics = performanceMetrics;
|
||||
_apiKeyService = apiKeyService;
|
||||
}
|
||||
|
||||
// -- Connection Management ------------------------------------
|
||||
|
||||
public override Task<Scada.ConnectResponse> Connect(
|
||||
Scada.ConnectRequest request, ServerCallContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_scadaClient.IsConnected)
|
||||
{
|
||||
return Task.FromResult(new Scada.ConnectResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "MxAccess is not connected"
|
||||
});
|
||||
}
|
||||
|
||||
var sessionId = _sessionManager.CreateSession(request.ClientId, request.ApiKey);
|
||||
|
||||
return Task.FromResult(new Scada.ConnectResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "Connected",
|
||||
SessionId = sessionId
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Connect failed for client {ClientId}", request.ClientId);
|
||||
return Task.FromResult(new Scada.ConnectResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<Scada.DisconnectResponse> Disconnect(
|
||||
Scada.DisconnectRequest request, ServerCallContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Terminate session first — prevents new Subscribe RPCs from passing
|
||||
// session validation while we clean up subscriptions
|
||||
var terminated = _sessionManager.TerminateSession(request.SessionId);
|
||||
|
||||
// Then clean up all subscriptions for this session
|
||||
_subscriptionManager.UnsubscribeSession(request.SessionId);
|
||||
|
||||
return Task.FromResult(new Scada.DisconnectResponse
|
||||
{
|
||||
Success = terminated,
|
||||
Message = terminated ? "Disconnected" : "Session not found"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Disconnect failed for session {SessionId}", request.SessionId);
|
||||
return Task.FromResult(new Scada.DisconnectResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public override Task<Scada.GetConnectionStateResponse> GetConnectionState(
|
||||
Scada.GetConnectionStateRequest request, ServerCallContext context)
|
||||
{
|
||||
var session = _sessionManager.GetSession(request.SessionId);
|
||||
return Task.FromResult(new Scada.GetConnectionStateResponse
|
||||
{
|
||||
IsConnected = _scadaClient.IsConnected,
|
||||
ClientId = session?.ClientId ?? "",
|
||||
ConnectedSinceUtcTicks = session?.ConnectedSinceUtcTicks ?? 0
|
||||
});
|
||||
}
|
||||
|
||||
// -- Read Operations ------------------------------------------
|
||||
|
||||
public override async Task<Scada.ReadResponse> Read(
|
||||
Scada.ReadRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
return new Scada.ReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid session",
|
||||
Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.Bad())
|
||||
};
|
||||
}
|
||||
|
||||
using var timing = _performanceMetrics?.BeginOperation("Read");
|
||||
try
|
||||
{
|
||||
var vtq = await _scadaClient.ReadAsync(request.Tag, context.CancellationToken);
|
||||
return new Scada.ReadResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = "",
|
||||
Vtq = ConvertToProtoVtq(request.Tag, vtq)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
timing?.SetSuccess(false);
|
||||
Log.Error(ex, "Read failed for tag {Tag}", request.Tag);
|
||||
return new Scada.ReadResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = ex.Message,
|
||||
Vtq = CreateBadVtq(request.Tag, QualityCodeMapper.BadCommunicationFailure())
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<Scada.ReadBatchResponse> ReadBatch(
|
||||
Scada.ReadBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
return new Scada.ReadBatchResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = "Invalid session"
|
||||
};
|
||||
}
|
||||
|
||||
using var timing = _performanceMetrics?.BeginOperation("ReadBatch");
|
||||
try
|
||||
{
|
||||
var results = await _scadaClient.ReadBatchAsync(request.Tags, context.CancellationToken);
|
||||
|
||||
var response = new Scada.ReadBatchResponse
|
||||
{
|
||||
Success = true,
|
||||
Message = ""
|
||||
};
|
||||
|
||||
// Return results in request order
|
||||
foreach (var tag in request.Tags)
|
||||
{
|
||||
if (results.TryGetValue(tag, out var vtq))
|
||||
{
|
||||
response.Vtqs.Add(ConvertToProtoVtq(tag, vtq));
|
||||
}
|
||||
else
|
||||
{
|
||||
response.Vtqs.Add(CreateBadVtq(tag, QualityCodeMapper.BadConfigurationError()));
|
||||
}
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
timing?.SetSuccess(false);
|
||||
Log.Error(ex, "ReadBatch failed");
|
||||
return new Scada.ReadBatchResponse
|
||||
{
|
||||
Success = false,
|
||||
Message = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// -- Write Operations -----------------------------------------
|
||||
|
||||
public override async Task<Scada.WriteResponse> Write(
|
||||
Scada.WriteRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
return new Scada.WriteResponse { Success = false, Message = "Invalid session" };
|
||||
}
|
||||
|
||||
using var timing = _performanceMetrics?.BeginOperation("Write");
|
||||
try
|
||||
{
|
||||
var value = TypedValueConverter.FromTypedValue(request.Value);
|
||||
await _scadaClient.WriteAsync(request.Tag, value!, context.CancellationToken);
|
||||
return new Scada.WriteResponse { Success = true, Message = "" };
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
timing?.SetSuccess(false);
|
||||
Log.Error(ex, "Write failed for tag {Tag}", request.Tag);
|
||||
return new Scada.WriteResponse { Success = false, Message = ex.Message };
|
||||
}
|
||||
}
|
||||
|
||||
public override async Task<Scada.WriteBatchResponse> WriteBatch(
|
||||
Scada.WriteBatchRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
return new Scada.WriteBatchResponse { Success = false, Message = "Invalid session" };
|
||||
}
|
||||
|
||||
using var timing = _performanceMetrics?.BeginOperation("WriteBatch");
|
||||
var response = new Scada.WriteBatchResponse { Success = true, Message = "" };
|
||||
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = TypedValueConverter.FromTypedValue(item.Value);
|
||||
await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken);
|
||||
response.Results.Add(new Scada.WriteResult
|
||||
{
|
||||
Tag = item.Tag, Success = true, Message = ""
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Results.Add(new Scada.WriteResult
|
||||
{
|
||||
Tag = item.Tag, Success = false, Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.Success)
|
||||
{
|
||||
timing?.SetSuccess(false);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
public override async Task<Scada.WriteBatchAndWaitResponse> WriteBatchAndWait(
|
||||
Scada.WriteBatchAndWaitRequest request, ServerCallContext context)
|
||||
{
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
return new Scada.WriteBatchAndWaitResponse { Success = false, Message = "Invalid session" };
|
||||
}
|
||||
|
||||
var response = new Scada.WriteBatchAndWaitResponse { Success = true };
|
||||
|
||||
try
|
||||
{
|
||||
// Execute writes and collect results
|
||||
foreach (var item in request.Items)
|
||||
{
|
||||
try
|
||||
{
|
||||
var value = TypedValueConverter.FromTypedValue(item.Value);
|
||||
await _scadaClient.WriteAsync(item.Tag, value!, context.CancellationToken);
|
||||
response.WriteResults.Add(new Scada.WriteResult
|
||||
{
|
||||
Tag = item.Tag, Success = true, Message = ""
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
response.Success = false;
|
||||
response.Message = "One or more writes failed";
|
||||
response.WriteResults.Add(new Scada.WriteResult
|
||||
{
|
||||
Tag = item.Tag, Success = false, Message = ex.Message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If any write failed, return immediately
|
||||
if (!response.Success)
|
||||
return response;
|
||||
|
||||
// Poll flag tag
|
||||
var flagValue = TypedValueConverter.FromTypedValue(request.FlagValue);
|
||||
var timeoutMs = request.TimeoutMs > 0 ? request.TimeoutMs : 5000;
|
||||
var pollIntervalMs = request.PollIntervalMs > 0 ? request.PollIntervalMs : 100;
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
while (sw.ElapsedMilliseconds < timeoutMs)
|
||||
{
|
||||
context.CancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
var vtq = await _scadaClient.ReadAsync(request.FlagTag, context.CancellationToken);
|
||||
if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue))
|
||||
{
|
||||
response.FlagReached = true;
|
||||
response.ElapsedMs = (int)sw.ElapsedMilliseconds;
|
||||
return response;
|
||||
}
|
||||
|
||||
await Task.Delay(pollIntervalMs, context.CancellationToken);
|
||||
}
|
||||
|
||||
// Timeout -- not an error
|
||||
response.FlagReached = false;
|
||||
response.ElapsedMs = (int)sw.ElapsedMilliseconds;
|
||||
return response;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "WriteBatchAndWait failed");
|
||||
return new Scada.WriteBatchAndWaitResponse
|
||||
{
|
||||
Success = false, Message = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// -- Subscription ---------------------------------------------
|
||||
|
||||
public override async Task Subscribe(
|
||||
Scada.SubscribeRequest request,
|
||||
IServerStreamWriter<Scada.VtqMessage> responseStream,
|
||||
ServerCallContext context)
|
||||
{
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid session"));
|
||||
}
|
||||
|
||||
var (reader, subscriptionId) = await _subscriptionManager.SubscribeAsync(
|
||||
request.SessionId, request.Tags, context.CancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// Use a combined approach: check both the gRPC cancellation token AND
|
||||
// periodic session validity. This works around Grpc.Core not reliably
|
||||
// firing CancellationToken on client disconnect.
|
||||
while (true)
|
||||
{
|
||||
// Wait for data with a timeout so we can periodically check session validity
|
||||
using (var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(30)))
|
||||
using (var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(
|
||||
context.CancellationToken, timeoutCts.Token))
|
||||
{
|
||||
bool hasData;
|
||||
try
|
||||
{
|
||||
hasData = await reader.WaitToReadAsync(linkedCts.Token);
|
||||
}
|
||||
catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested
|
||||
&& !context.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// Timeout expired, not a client disconnect — check if session is still valid
|
||||
if (!_sessionManager.ValidateSession(request.SessionId))
|
||||
{
|
||||
Log.Information("Subscribe stream ending — session {SessionId} no longer valid",
|
||||
request.SessionId);
|
||||
break;
|
||||
}
|
||||
continue; // Session still valid, keep waiting
|
||||
}
|
||||
|
||||
if (!hasData) break; // Channel completed
|
||||
|
||||
while (reader.TryRead(out var item))
|
||||
{
|
||||
var protoVtq = ConvertToProtoVtq(item.address, item.vtq);
|
||||
await responseStream.WriteAsync(protoVtq);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Client disconnected -- normal
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Subscribe stream error for session {SessionId} subscription {SubscriptionId}",
|
||||
request.SessionId, subscriptionId);
|
||||
throw new RpcException(new GrpcStatus(StatusCode.Internal, ex.Message));
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Clean up THIS subscription only, not the entire session
|
||||
_subscriptionManager.UnsubscribeSubscription(subscriptionId);
|
||||
}
|
||||
}
|
||||
|
||||
// -- API Key Check --------------------------------------------
|
||||
|
||||
public override Task<Scada.CheckApiKeyResponse> CheckApiKey(
|
||||
Scada.CheckApiKeyRequest request, ServerCallContext context)
|
||||
{
|
||||
// Check the API key from the request body against the key store.
|
||||
var isValid = _apiKeyService != null && _apiKeyService.ValidateApiKey(request.ApiKey) != null;
|
||||
return Task.FromResult(new Scada.CheckApiKeyResponse
|
||||
{
|
||||
IsValid = isValid,
|
||||
Message = isValid ? "Valid" : "Invalid"
|
||||
});
|
||||
}
|
||||
|
||||
// -- Helpers --------------------------------------------------
|
||||
|
||||
/// <summary>Converts a domain Vtq to a proto VtqMessage.</summary>
|
||||
private static Scada.VtqMessage ConvertToProtoVtq(string tag, Vtq vtq)
|
||||
{
|
||||
return new Scada.VtqMessage
|
||||
{
|
||||
Tag = tag,
|
||||
Value = TypedValueConverter.ToTypedValue(vtq.Value),
|
||||
TimestampUtcTicks = vtq.Timestamp.Ticks,
|
||||
Quality = QualityCodeMapper.ToQualityCode(vtq.Quality)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Creates a VtqMessage with bad quality for error responses.</summary>
|
||||
private static Scada.VtqMessage CreateBadVtq(string tag, Scada.QualityCode quality)
|
||||
{
|
||||
return new Scada.VtqMessage
|
||||
{
|
||||
Tag = tag,
|
||||
TimestampUtcTicks = DateTime.UtcNow.Ticks,
|
||||
Quality = quality
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Health
|
||||
{
|
||||
/// <summary>
|
||||
/// Basic health check: connection state, success rate, client count.
|
||||
/// </summary>
|
||||
public class HealthCheckService : IHealthCheck
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<HealthCheckService>();
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
private readonly PerformanceMetrics _performanceMetrics;
|
||||
|
||||
public HealthCheckService(
|
||||
IScadaClient scadaClient,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics performanceMetrics)
|
||||
{
|
||||
_scadaClient = scadaClient;
|
||||
_subscriptionManager = subscriptionManager;
|
||||
_performanceMetrics = performanceMetrics;
|
||||
}
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(
|
||||
HealthCheckContext context,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
var data = new Dictionary<string, object>();
|
||||
|
||||
var isConnected = _scadaClient.IsConnected;
|
||||
data["scada_connected"] = isConnected;
|
||||
data["scada_connection_state"] = _scadaClient.ConnectionState.ToString();
|
||||
|
||||
var subscriptionStats = _subscriptionManager.GetStats();
|
||||
data["subscription_total_clients"] = subscriptionStats.TotalClients;
|
||||
data["subscription_total_tags"] = subscriptionStats.TotalTags;
|
||||
|
||||
long totalOperations = 0;
|
||||
double totalSuccessRate = 0;
|
||||
int operationCount = 0;
|
||||
|
||||
foreach (var kvp in _performanceMetrics.GetAllMetrics())
|
||||
{
|
||||
var stats = kvp.Value.GetStatistics();
|
||||
totalOperations += stats.TotalCount;
|
||||
totalSuccessRate += stats.SuccessRate;
|
||||
operationCount++;
|
||||
}
|
||||
|
||||
double averageSuccessRate = operationCount > 0
|
||||
? totalSuccessRate / operationCount
|
||||
: 1.0;
|
||||
|
||||
data["total_operations"] = totalOperations;
|
||||
data["average_success_rate"] = averageSuccessRate;
|
||||
|
||||
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(
|
||||
"Average success rate is below 50%", 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: data));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Health check failed");
|
||||
return Task.FromResult(HealthCheckResult.Unhealthy(
|
||||
"Health check failed: " + ex.Message, ex));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Grpc.Services;
|
||||
using ZB.MOM.WW.LmxProxy.Host.MxAccess;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Security;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Health;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Sessions;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Status;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host
|
||||
{
|
||||
/// <summary>
|
||||
/// Service lifecycle manager. Created by Topshelf, handles Start/Stop/Pause/Continue.
|
||||
/// </summary>
|
||||
public class LmxProxyService
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<LmxProxyService>();
|
||||
|
||||
private readonly LmxProxyConfiguration _config;
|
||||
|
||||
private MxAccessClient? _mxAccessClient;
|
||||
private SessionManager? _sessionManager;
|
||||
private SubscriptionManager? _subscriptionManager;
|
||||
private ApiKeyService? _apiKeyService;
|
||||
private PerformanceMetrics? _performanceMetrics;
|
||||
private HealthCheckService? _healthCheckService;
|
||||
private StatusReportService? _statusReportService;
|
||||
private StatusWebServer? _statusWebServer;
|
||||
private Server? _grpcServer;
|
||||
|
||||
public LmxProxyService(LmxProxyConfiguration config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Topshelf Start callback. Creates and starts all components.
|
||||
/// </summary>
|
||||
public bool Start()
|
||||
{
|
||||
try
|
||||
{
|
||||
Log.Information("LmxProxy service starting...");
|
||||
|
||||
// 1. Validate configuration
|
||||
ConfigurationValidator.ValidateAndLog(_config);
|
||||
|
||||
// 2. Check/generate TLS certificates
|
||||
var credentials = TlsCertificateManager.CreateServerCredentials(_config.Tls);
|
||||
|
||||
// 3. Create ApiKeyService
|
||||
_apiKeyService = new ApiKeyService(_config.ApiKeyConfigFile);
|
||||
|
||||
// 4. Create MxAccessClient
|
||||
_mxAccessClient = new MxAccessClient(
|
||||
maxConcurrentOperations: _config.Connection.MaxConcurrentOperations,
|
||||
readTimeoutSeconds: _config.Connection.ReadTimeoutSeconds,
|
||||
writeTimeoutSeconds: _config.Connection.WriteTimeoutSeconds,
|
||||
monitorIntervalSeconds: _config.Connection.MonitorIntervalSeconds,
|
||||
autoReconnect: _config.Connection.AutoReconnect,
|
||||
nodeName: _config.Connection.NodeName,
|
||||
galaxyName: _config.Connection.GalaxyName,
|
||||
probeTestTagAddress: _config.HealthCheck.TestTagAddress,
|
||||
probeStaleThresholdMs: _config.HealthCheck.ProbeStaleThresholdMs,
|
||||
clientName: _config.ClientName);
|
||||
|
||||
// 5. Connect to MxAccess synchronously (with timeout)
|
||||
Log.Information("Connecting to MxAccess (timeout: {Timeout}s)...",
|
||||
_config.Connection.ConnectionTimeoutSeconds);
|
||||
using (var cts = new CancellationTokenSource(
|
||||
TimeSpan.FromSeconds(_config.Connection.ConnectionTimeoutSeconds)))
|
||||
{
|
||||
_mxAccessClient.ConnectAsync(cts.Token).GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
// 6. Start auto-reconnect monitor
|
||||
_mxAccessClient.StartMonitorLoop();
|
||||
|
||||
// 7. Create SubscriptionManager
|
||||
var channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropOldest;
|
||||
if (_config.Subscription.ChannelFullMode.Equals("DropNewest", StringComparison.OrdinalIgnoreCase))
|
||||
channelFullMode = System.Threading.Channels.BoundedChannelFullMode.DropNewest;
|
||||
else if (_config.Subscription.ChannelFullMode.Equals("Wait", StringComparison.OrdinalIgnoreCase))
|
||||
channelFullMode = System.Threading.Channels.BoundedChannelFullMode.Wait;
|
||||
|
||||
_subscriptionManager = new SubscriptionManager(
|
||||
_mxAccessClient, _config.Subscription.ChannelCapacity, channelFullMode);
|
||||
|
||||
// Wire MxAccessClient data change events to SubscriptionManager
|
||||
_mxAccessClient.OnTagValueChanged = _subscriptionManager.OnTagValueChanged;
|
||||
|
||||
// Wire MxAccessClient disconnect to SubscriptionManager
|
||||
_mxAccessClient.ConnectionStateChanged += (sender, e) =>
|
||||
{
|
||||
if (e.CurrentState == Domain.ConnectionState.Disconnected ||
|
||||
e.CurrentState == Domain.ConnectionState.Error)
|
||||
{
|
||||
_subscriptionManager.NotifyDisconnection();
|
||||
}
|
||||
else if (e.CurrentState == Domain.ConnectionState.Connected &&
|
||||
e.PreviousState == Domain.ConnectionState.Reconnecting)
|
||||
{
|
||||
_subscriptionManager.NotifyReconnection();
|
||||
}
|
||||
};
|
||||
|
||||
// 8. Create SessionManager
|
||||
_sessionManager = new SessionManager(inactivityTimeoutMinutes: 5);
|
||||
_sessionManager.OnSessionScavenged(sessionId =>
|
||||
{
|
||||
Log.Information("Cleaning up subscriptions for scavenged session {SessionId}", sessionId);
|
||||
_subscriptionManager.UnsubscribeSession(sessionId);
|
||||
});
|
||||
|
||||
// 9. Create performance metrics
|
||||
_performanceMetrics = new PerformanceMetrics();
|
||||
|
||||
// 10. Create health check services
|
||||
_healthCheckService = new HealthCheckService(_mxAccessClient, _subscriptionManager, _performanceMetrics);
|
||||
|
||||
// 11. Create status report service
|
||||
_statusReportService = new StatusReportService(
|
||||
_mxAccessClient, _subscriptionManager, _performanceMetrics,
|
||||
_healthCheckService);
|
||||
|
||||
// 12. Start status web server
|
||||
_statusWebServer = new StatusWebServer(_config.WebServer, _statusReportService);
|
||||
if (!_statusWebServer.Start())
|
||||
{
|
||||
Log.Warning("Status web server failed to start — continuing without it");
|
||||
}
|
||||
|
||||
// 13. Create gRPC service
|
||||
var grpcService = new ScadaGrpcService(
|
||||
_mxAccessClient, _sessionManager, _subscriptionManager, _performanceMetrics, _apiKeyService);
|
||||
|
||||
// 14. Create and configure interceptor
|
||||
var interceptor = new ApiKeyInterceptor(_apiKeyService);
|
||||
|
||||
// 15. Build and start gRPC server
|
||||
_grpcServer = new Server
|
||||
{
|
||||
Services =
|
||||
{
|
||||
Scada.ScadaService.BindService(grpcService)
|
||||
.Intercept(interceptor)
|
||||
},
|
||||
Ports =
|
||||
{
|
||||
new ServerPort("0.0.0.0", _config.GrpcPort, credentials)
|
||||
}
|
||||
};
|
||||
|
||||
_grpcServer.Start();
|
||||
Log.Information("gRPC server started on port {Port}", _config.GrpcPort);
|
||||
|
||||
Log.Information("LmxProxy service started successfully");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "LmxProxy service failed to start");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Topshelf Stop callback. Stops and disposes all components in reverse order.
|
||||
/// </summary>
|
||||
public bool Stop()
|
||||
{
|
||||
Log.Information("LmxProxy service stopping...");
|
||||
|
||||
try
|
||||
{
|
||||
// 1. Stop reconnect monitor (5s wait)
|
||||
_mxAccessClient?.StopMonitorLoop();
|
||||
|
||||
// 2. Stop status web server
|
||||
_statusWebServer?.Stop();
|
||||
|
||||
// 3. Dispose performance metrics
|
||||
_performanceMetrics?.Dispose();
|
||||
|
||||
// 4. Graceful gRPC shutdown (10s timeout, then kill)
|
||||
if (_grpcServer != null)
|
||||
{
|
||||
Log.Information("Shutting down gRPC server...");
|
||||
_grpcServer.ShutdownAsync().Wait(TimeSpan.FromSeconds(10));
|
||||
Log.Information("gRPC server stopped");
|
||||
}
|
||||
|
||||
// 3. Dispose components in reverse order
|
||||
_subscriptionManager?.Dispose();
|
||||
_sessionManager?.Dispose();
|
||||
_apiKeyService?.Dispose();
|
||||
|
||||
// 4. Disconnect MxAccess (10s timeout)
|
||||
if (_mxAccessClient != null)
|
||||
{
|
||||
Log.Information("Disconnecting from MxAccess...");
|
||||
_mxAccessClient.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(10));
|
||||
Log.Information("MxAccess disconnected");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error during shutdown");
|
||||
}
|
||||
|
||||
Log.Information("LmxProxy service stopped");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Topshelf Pause callback -- no-op.</summary>
|
||||
public bool Pause()
|
||||
{
|
||||
Log.Information("LmxProxy service paused (no-op)");
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Topshelf Continue callback -- no-op.</summary>
|
||||
public bool Continue()
|
||||
{
|
||||
Log.Information("LmxProxy service continued (no-op)");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
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.Metrics
|
||||
{
|
||||
/// <summary>
|
||||
/// Disposable scope returned by <see cref="PerformanceMetrics.BeginOperation"/>.
|
||||
/// </summary>
|
||||
public interface ITimingScope : IDisposable
|
||||
{
|
||||
void SetSuccess(bool success);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Statistics snapshot for a single operation type.
|
||||
/// </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; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-operation timing and success tracking with a rolling buffer for percentile computation.
|
||||
/// </summary>
|
||||
public class OperationMetrics
|
||||
{
|
||||
private readonly List<double> _durations = new List<double>();
|
||||
private readonly object _lock = new object();
|
||||
private long _totalCount;
|
||||
private long _successCount;
|
||||
private double _totalMilliseconds;
|
||||
private double _minMilliseconds = double.MaxValue;
|
||||
private double _maxMilliseconds;
|
||||
|
||||
public void Record(TimeSpan duration, bool success)
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_totalCount++;
|
||||
if (success)
|
||||
{
|
||||
_successCount++;
|
||||
}
|
||||
|
||||
var ms = duration.TotalMilliseconds;
|
||||
_durations.Add(ms);
|
||||
_totalMilliseconds += ms;
|
||||
|
||||
if (ms < _minMilliseconds)
|
||||
_minMilliseconds = ms;
|
||||
if (ms > _maxMilliseconds)
|
||||
_maxMilliseconds = ms;
|
||||
|
||||
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();
|
||||
var p95Index = (int)Math.Ceiling(sortedDurations.Count * 0.95) - 1;
|
||||
p95Index = Math.Max(0, p95Index);
|
||||
|
||||
return new MetricsStatistics
|
||||
{
|
||||
TotalCount = _totalCount,
|
||||
SuccessCount = _successCount,
|
||||
SuccessRate = (double)_successCount / _totalCount,
|
||||
AverageMilliseconds = _totalMilliseconds / _totalCount,
|
||||
MinMilliseconds = _minMilliseconds,
|
||||
MaxMilliseconds = _maxMilliseconds,
|
||||
Percentile95Milliseconds = sortedDurations[p95Index]
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tracks per-operation performance metrics with periodic logging.
|
||||
/// </summary>
|
||||
public class PerformanceMetrics : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<PerformanceMetrics>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, OperationMetrics> _metrics
|
||||
= new ConcurrentDictionary<string, OperationMetrics>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Timer _reportingTimer;
|
||||
private bool _disposed;
|
||||
|
||||
public PerformanceMetrics()
|
||||
{
|
||||
_reportingTimer = new Timer(ReportMetrics, null,
|
||||
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
|
||||
public void RecordOperation(string operationName, TimeSpan duration, bool success = true)
|
||||
{
|
||||
var metrics = _metrics.GetOrAdd(operationName, _ => new OperationMetrics());
|
||||
metrics.Record(duration, success);
|
||||
}
|
||||
|
||||
public ITimingScope BeginOperation(string operationName)
|
||||
{
|
||||
return new TimingScope(this, operationName);
|
||||
}
|
||||
|
||||
public OperationMetrics? GetMetrics(string operationName)
|
||||
{
|
||||
return _metrics.TryGetValue(operationName, out var metrics) ? metrics : null;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, OperationMetrics> GetAllMetrics()
|
||||
{
|
||||
return _metrics;
|
||||
}
|
||||
|
||||
public Dictionary<string, MetricsStatistics> GetStatistics()
|
||||
{
|
||||
var result = new Dictionary<string, MetricsStatistics>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var kvp in _metrics)
|
||||
{
|
||||
result[kvp.Key] = kvp.Value.GetStatistics();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private void ReportMetrics(object? state)
|
||||
{
|
||||
foreach (var kvp in _metrics)
|
||||
{
|
||||
var stats = kvp.Value.GetStatistics();
|
||||
if (stats.TotalCount == 0) continue;
|
||||
|
||||
Logger.Information(
|
||||
"Metrics: {Operation} — Count={Count}, SuccessRate={SuccessRate:P1}, " +
|
||||
"AvgMs={AverageMs:F1}, MinMs={MinMs:F1}, MaxMs={MaxMs:F1}, P95Ms={P95Ms:F1}",
|
||||
kvp.Key, stats.TotalCount, stats.SuccessRate,
|
||||
stats.AverageMilliseconds, stats.MinMilliseconds,
|
||||
stats.MaxMilliseconds, stats.Percentile95Milliseconds);
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
_reportingTimer.Dispose();
|
||||
ReportMetrics(null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposable timing scope that records duration on dispose.
|
||||
/// </summary>
|
||||
private class TimingScope : ITimingScope
|
||||
{
|
||||
private readonly PerformanceMetrics _metrics;
|
||||
private readonly string _operationName;
|
||||
private readonly Stopwatch _stopwatch;
|
||||
private bool _success = true;
|
||||
private bool _disposed;
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Connects to MxAccess on the dedicated STA thread.
|
||||
/// </summary>
|
||||
public async Task ConnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(MxAccessClient));
|
||||
if (IsConnected) return;
|
||||
|
||||
SetState(ConnectionState.Connecting);
|
||||
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() => ConnectInternal());
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
_connectedSince = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
SetState(ConnectionState.Connected);
|
||||
Log.Information("Connected to MxAccess (handle={Handle})", _connectionHandle);
|
||||
|
||||
// Recreate any stored subscriptions from a previous connection
|
||||
await RecreateStoredSubscriptionsAsync();
|
||||
|
||||
// Start persistent probe subscription
|
||||
await StartProbeSubscriptionAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to connect to MxAccess");
|
||||
await CleanupComObjectsAsync();
|
||||
SetState(ConnectionState.Error, ex.Message);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disconnects from MxAccess on the dedicated STA thread.
|
||||
/// </summary>
|
||||
public async Task DisconnectAsync(CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected) return;
|
||||
|
||||
SetState(ConnectionState.Disconnecting);
|
||||
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() => DisconnectInternal());
|
||||
|
||||
SetState(ConnectionState.Disconnected);
|
||||
Log.Information("Disconnected from MxAccess");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error during disconnect");
|
||||
SetState(ConnectionState.Error, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the auto-reconnect monitor loop.
|
||||
/// Call this after initial ConnectAsync succeeds.
|
||||
/// </summary>
|
||||
public void StartMonitorLoop()
|
||||
{
|
||||
if (!_autoReconnect) return;
|
||||
|
||||
_reconnectCts = new CancellationTokenSource();
|
||||
Task.Run(() => MonitorConnectionAsync(_reconnectCts.Token));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stops the auto-reconnect monitor loop.
|
||||
/// </summary>
|
||||
public void StopMonitorLoop()
|
||||
{
|
||||
_reconnectCts?.Cancel();
|
||||
}
|
||||
|
||||
/// <summary>Gets the UTC time when the connection was established.</summary>
|
||||
public DateTime ConnectedSince
|
||||
{
|
||||
get { lock (_lock) { return _connectedSince; } }
|
||||
}
|
||||
|
||||
/// <summary>Gets the number of times the client has reconnected since startup.</summary>
|
||||
public int ReconnectCount => _reconnectCount;
|
||||
|
||||
// ── Internal synchronous methods ──────────
|
||||
|
||||
private void ConnectInternal()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
// Create COM object
|
||||
_lmxProxy = new LMXProxyServer();
|
||||
|
||||
// Wire event handlers
|
||||
_lmxProxy.OnDataChange += OnDataChange;
|
||||
_lmxProxy.OnWriteComplete += OnWriteComplete;
|
||||
|
||||
// Register with MxAccess using unique client name
|
||||
_connectionHandle = _lmxProxy.Register(_clientName);
|
||||
Log.Information("Registered with MxAccess as '{ClientName}'", _clientName);
|
||||
|
||||
if (_connectionHandle <= 0)
|
||||
{
|
||||
throw new InvalidOperationException("Failed to register with MxAccess - invalid handle returned");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void DisconnectInternal()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lmxProxy == null || _connectionHandle <= 0) return;
|
||||
|
||||
try
|
||||
{
|
||||
// Unadvise all active subscriptions before unregistering
|
||||
foreach (var kvp in new Dictionary<string, int>(_addressToHandle))
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, kvp.Value);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, kvp.Value);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error removing subscription for {Address} during disconnect", kvp.Key);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove event handlers
|
||||
_lmxProxy.OnDataChange -= OnDataChange;
|
||||
_lmxProxy.OnWriteComplete -= OnWriteComplete;
|
||||
|
||||
// Unregister
|
||||
_lmxProxy.Unregister(_connectionHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during MxAccess unregister");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Force-release COM object
|
||||
try
|
||||
{
|
||||
Marshal.ReleaseComObject(_lmxProxy);
|
||||
}
|
||||
catch { }
|
||||
|
||||
_lmxProxy = null;
|
||||
_connectionHandle = 0;
|
||||
|
||||
// Clear handle tracking (but keep _storedSubscriptions for reconnect)
|
||||
_handleToAddress.Clear();
|
||||
_addressToHandle.Clear();
|
||||
_pendingWrites.Clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Subscribes to the configured probe test tag so that OnDataChange
|
||||
/// callbacks update <see cref="_lastProbeValueTime"/>. Called after
|
||||
/// connect (and reconnect). The subscription is stored for reconnect
|
||||
/// replay like any other subscription.
|
||||
/// </summary>
|
||||
private async Task StartProbeSubscriptionAsync()
|
||||
{
|
||||
if (_probeTestTagAddress == null) return;
|
||||
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null) return;
|
||||
|
||||
// Subscribe (skips if already subscribed from reconnect replay)
|
||||
SubscribeInternal(_probeTestTagAddress);
|
||||
|
||||
// Store a no-op callback — the real work happens in OnProbeDataChange
|
||||
// which is called from OnDataChange before the stored callback
|
||||
_storedSubscriptions[_probeTestTagAddress] = (_, __) => { };
|
||||
}
|
||||
});
|
||||
|
||||
Log.Information("Probe subscription started for {Tag} (stale threshold={ThresholdMs}ms)",
|
||||
_probeTestTagAddress, _probeStaleThresholdMs);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from <see cref="OnDataChange"/> when a value arrives for the probe tag.
|
||||
/// Updates the last-seen timestamp so the monitor loop can detect staleness.
|
||||
/// </summary>
|
||||
internal void OnProbeDataChange(string address, Vtq vtq)
|
||||
{
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Auto-reconnect monitor loop with persistent subscription probe.
|
||||
/// - If disconnected: attempt reconnect.
|
||||
/// - If connected and probe configured: check time since last probe value update.
|
||||
/// If stale beyond threshold, force disconnect and reconnect.
|
||||
/// </summary>
|
||||
private async Task MonitorConnectionAsync(CancellationToken ct)
|
||||
{
|
||||
Log.Information("Connection monitor loop started (interval={IntervalMs}ms, probe={ProbeEnabled}, staleThreshold={StaleMs}ms)",
|
||||
_monitorIntervalMs, _probeTestTagAddress != null, _probeStaleThresholdMs);
|
||||
|
||||
while (!ct.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(_monitorIntervalMs, ct);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
|
||||
// -- Case 1: Already disconnected --
|
||||
if (!IsConnected)
|
||||
{
|
||||
await AttemptReconnectAsync(ct);
|
||||
// Reset probe timer so the next check gives the new connection
|
||||
// a full interval to deliver its first OnDataChange callback
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
continue;
|
||||
}
|
||||
|
||||
// -- Case 2: Connected, no probe configured --
|
||||
if (_probeTestTagAddress == null)
|
||||
continue;
|
||||
|
||||
// -- Case 3: Connected, check probe staleness --
|
||||
var elapsed = DateTime.UtcNow - _lastProbeValueTime;
|
||||
if (elapsed.TotalMilliseconds > _probeStaleThresholdMs)
|
||||
{
|
||||
Log.Warning("Probe tag {Tag} stale for {ElapsedMs}ms (threshold={ThresholdMs}ms) — forcing reconnect",
|
||||
_probeTestTagAddress, (int)elapsed.TotalMilliseconds, _probeStaleThresholdMs);
|
||||
|
||||
try
|
||||
{
|
||||
await DisconnectAsync(ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during forced disconnect before reconnect");
|
||||
}
|
||||
|
||||
await AttemptReconnectAsync(ct);
|
||||
_lastProbeValueTime = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
Log.Information("Connection monitor loop exited");
|
||||
}
|
||||
|
||||
private async Task AttemptReconnectAsync(CancellationToken ct)
|
||||
{
|
||||
Log.Information("Attempting reconnect...");
|
||||
SetState(ConnectionState.Reconnecting);
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAsync(ct);
|
||||
Interlocked.Increment(ref _reconnectCount);
|
||||
Log.Information("Reconnected to MxAccess successfully (reconnect #{Count})", _reconnectCount);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Let the outer loop handle cancellation
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Reconnect attempt failed, will retry at next interval");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cleans up COM objects on the dedicated STA thread after a failed connection.
|
||||
/// </summary>
|
||||
private async Task CleanupComObjectsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (_lmxProxy != null)
|
||||
{
|
||||
try { _lmxProxy.OnDataChange -= OnDataChange; } catch { }
|
||||
try { _lmxProxy.OnWriteComplete -= OnWriteComplete; } catch { }
|
||||
try { Marshal.ReleaseComObject(_lmxProxy); } catch { }
|
||||
_lmxProxy = null;
|
||||
}
|
||||
_connectionHandle = 0;
|
||||
_handleToAddress.Clear();
|
||||
_addressToHandle.Clear();
|
||||
_pendingWrites.Clear();
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during COM object cleanup");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Callback invoked by the SubscriptionManager when it needs to deliver
|
||||
/// data change events. Set by the SubscriptionManager during initialization.
|
||||
/// </summary>
|
||||
public Action<string, Vtq>? OnTagValueChanged { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// COM event handler for MxAccess OnDataChange events.
|
||||
/// Signature matches the ArchestrA.MxAccess ILMXProxyServerEvents interface.
|
||||
/// </summary>
|
||||
private void OnDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
int pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
var quality = MapQuality(pwItemQuality);
|
||||
var timestamp = ConvertTimestamp(pftItemTimeStamp);
|
||||
|
||||
// Check MXSTATUS_PROXY — if success is false, override quality
|
||||
// with a more specific code derived from the MxAccess status fields
|
||||
if (ItemStatus != null && ItemStatus.Length > 0 && ItemStatus[0].success == 0)
|
||||
{
|
||||
var status = ItemStatus[0];
|
||||
quality = MxStatusMapper.CategoryToQuality((int)status.category, status.detail);
|
||||
Log.Debug("OnDataChange status failure for handle {Handle}: {Status}",
|
||||
phItemHandle, MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy));
|
||||
}
|
||||
|
||||
var vtq = new Vtq(pvItemValue, timestamp, quality);
|
||||
|
||||
// Resolve address from handle map
|
||||
string address;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_handleToAddress.TryGetValue(phItemHandle, out address))
|
||||
{
|
||||
Log.Debug("OnDataChange for unknown handle {Handle}, ignoring", phItemHandle);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke the stored subscription callback
|
||||
Action<string, Vtq> callback;
|
||||
lock (_lock)
|
||||
{
|
||||
if (!_storedSubscriptions.TryGetValue(address, out callback))
|
||||
{
|
||||
Log.Debug("OnDataChange for {Address} but no callback registered", address);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Update probe timestamp if this is the probe tag
|
||||
if (_probeTestTagAddress != null &&
|
||||
string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
OnProbeDataChange(address, vtq);
|
||||
}
|
||||
|
||||
callback.Invoke(address, vtq);
|
||||
|
||||
// Also route to the SubscriptionManager's global handler
|
||||
OnTagValueChanged?.Invoke(address, vtq);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error processing OnDataChange event for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// COM event handler for MxAccess OnWriteComplete events.
|
||||
/// Resolves the pending TaskCompletionSource so the caller gets
|
||||
/// confirmation (or error) from the OnWriteComplete callback.
|
||||
/// </summary>
|
||||
private void OnWriteComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] ItemStatus)
|
||||
{
|
||||
try
|
||||
{
|
||||
TaskCompletionSource<bool> tcs;
|
||||
bool hasPending;
|
||||
lock (_lock)
|
||||
{
|
||||
hasPending = _pendingWrites.TryGetValue(phItemHandle, out tcs);
|
||||
}
|
||||
|
||||
if (ItemStatus != null && ItemStatus.Length > 0)
|
||||
{
|
||||
var status = ItemStatus[0];
|
||||
if (status.success == 0)
|
||||
{
|
||||
string errorMsg = MxStatusMapper.FormatStatus(status.detail, (int)status.category, (int)status.detectedBy);
|
||||
Log.Warning("OnWriteComplete: write failed for handle {Handle}: {Status}", phItemHandle, errorMsg);
|
||||
if (hasPending) tcs.TrySetException(new InvalidOperationException("Write failed: " + errorMsg));
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug("OnWriteComplete: write succeeded for handle {Handle}", phItemHandle);
|
||||
if (hasPending) tcs.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Debug("OnWriteComplete: no status for handle {Handle}", phItemHandle);
|
||||
tcs?.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Error processing OnWriteComplete event for handle {Handle}", phItemHandle);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a timestamp object to DateTime in UTC.
|
||||
/// </summary>
|
||||
private static DateTime ConvertTimestamp(object timestamp)
|
||||
{
|
||||
if (timestamp is DateTime dt)
|
||||
{
|
||||
return dt.Kind == DateTimeKind.Utc ? dt : dt.ToUniversalTime();
|
||||
}
|
||||
|
||||
return DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Reads a single tag value from MxAccess.
|
||||
/// Uses subscribe-get-first-value-unsubscribe pattern (same as v1).
|
||||
/// </summary>
|
||||
public async Task<Vtq> ReadAsync(string address, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected)
|
||||
return Vtq.New(null, Quality.Bad_NotConnected);
|
||||
|
||||
await _readSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
return await ReadSingleValueAsync(address, ct);
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException comEx)
|
||||
{
|
||||
Log.Error(comEx, "COM read error for tag {Address}: HRESULT=0x{ErrorCode:X8}", address, comEx.ErrorCode);
|
||||
return Vtq.New(null, Quality.Bad_CommFailure);
|
||||
}
|
||||
catch (TimeoutException)
|
||||
{
|
||||
Log.Warning("Read timed out for tag {Address}", address);
|
||||
return Vtq.New(null, Quality.Bad_CommFailure);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "ReadAsync failed for tag {Address}", address);
|
||||
return Vtq.New(null, Quality.Bad_CommFailure);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_readSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads multiple tags with semaphore-controlled concurrency (max 10 concurrent).
|
||||
/// Each tag is read independently. Partial failures return Bad quality for failed tags.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyDictionary<string, Vtq>> ReadBatchAsync(
|
||||
IEnumerable<string> addresses, CancellationToken ct = default)
|
||||
{
|
||||
var addressList = addresses.ToList();
|
||||
var results = new Dictionary<string, Vtq>(addressList.Count, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var tasks = addressList.Select(async address =>
|
||||
{
|
||||
var vtq = await ReadAsync(address, ct);
|
||||
return (address, vtq);
|
||||
});
|
||||
|
||||
foreach (var task in await Task.WhenAll(tasks))
|
||||
{
|
||||
results[task.address] = task.vtq;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a single tag value to MxAccess.
|
||||
/// Uses Task.Run for COM calls. Write completes synchronously (fire-and-forget).
|
||||
/// </summary>
|
||||
public async Task WriteAsync(string address, object value, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
await _writeSemaphore.WaitAsync(ct);
|
||||
try
|
||||
{
|
||||
await WriteInternalAsync(address, value, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeSemaphore.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes multiple tag values with semaphore-controlled concurrency.
|
||||
/// </summary>
|
||||
public async Task WriteBatchAsync(
|
||||
IReadOnlyDictionary<string, object> values, CancellationToken ct = default)
|
||||
{
|
||||
var tasks = values.Select(async kvp =>
|
||||
{
|
||||
await WriteAsync(kvp.Key, kvp.Value, ct);
|
||||
});
|
||||
|
||||
await Task.WhenAll(tasks);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes a batch, then polls flagTag until it equals flagValue or timeout expires.
|
||||
/// Uses type-aware comparison via TypedValueComparer.
|
||||
/// </summary>
|
||||
public async Task<(bool flagReached, int elapsedMs)> WriteBatchAndWaitAsync(
|
||||
IReadOnlyDictionary<string, object> values,
|
||||
string flagTag,
|
||||
object flagValue,
|
||||
int timeoutMs,
|
||||
int pollIntervalMs,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
// Write all values first
|
||||
await WriteBatchAsync(values, ct);
|
||||
|
||||
// Poll flag tag
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var effectiveTimeout = timeoutMs > 0 ? timeoutMs : 5000;
|
||||
var effectiveInterval = pollIntervalMs > 0 ? pollIntervalMs : 100;
|
||||
|
||||
while (sw.ElapsedMilliseconds < effectiveTimeout)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
var vtq = await ReadAsync(flagTag, ct);
|
||||
if (vtq.Quality.IsGood() && TypedValueComparer.Equals(vtq.Value, flagValue))
|
||||
{
|
||||
return (true, (int)sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
await Task.Delay(effectiveInterval, ct);
|
||||
}
|
||||
|
||||
return (false, (int)sw.ElapsedMilliseconds);
|
||||
}
|
||||
|
||||
// ── Private read/write helpers ──────────
|
||||
|
||||
/// <summary>
|
||||
/// Reads a single value by subscribing, waiting for the first data change callback,
|
||||
/// then unsubscribing. This is the same pattern as v1.
|
||||
/// </summary>
|
||||
private async Task<Vtq> ReadSingleValueAsync(string address, CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<Vtq>();
|
||||
IAsyncDisposable? subscription = null;
|
||||
|
||||
try
|
||||
{
|
||||
subscription = await SubscribeAsync(
|
||||
new[] { address },
|
||||
(addr, vtq) => { tcs.TrySetResult(vtq); },
|
||||
ct);
|
||||
|
||||
return await WaitForReadResultAsync(tcs, ct);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (subscription != null)
|
||||
{
|
||||
await subscription.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits for a read result with timeout.
|
||||
/// </summary>
|
||||
private async Task<Vtq> WaitForReadResultAsync(TaskCompletionSource<Vtq> tcs, CancellationToken ct)
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(_readTimeoutMs))
|
||||
using (ct.Register(() => cts.Cancel()))
|
||||
{
|
||||
cts.Token.Register(() => tcs.TrySetException(
|
||||
new TimeoutException("Read timeout")));
|
||||
return await tcs.Task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Internal write implementation dispatched on the STA thread.
|
||||
/// Registers a TaskCompletionSource, calls Write(), then awaits the
|
||||
/// OnWriteComplete callback via the STA message pump. Falls back to
|
||||
/// fire-and-forget if the callback doesn't arrive within the timeout.
|
||||
/// </summary>
|
||||
private async Task WriteInternalAsync(string address, object value, CancellationToken ct)
|
||||
{
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
int itemHandle = 0;
|
||||
|
||||
// Step 1: Setup and write on the STA thread
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
try
|
||||
{
|
||||
itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
|
||||
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
|
||||
// Register for OnWriteComplete callback
|
||||
_pendingWrites[itemHandle] = tcs;
|
||||
|
||||
// Write the value (-1 = no security classification)
|
||||
_lmxProxy.Write(_connectionHandle, itemHandle, value, -1);
|
||||
|
||||
Log.Debug("Write dispatched for {Address} (handle={Handle}), awaiting OnWriteComplete",
|
||||
address, itemHandle);
|
||||
}
|
||||
catch (System.Runtime.InteropServices.COMException comEx)
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
string enriched = string.Format("Write failed for '{0}': COM error 0x{1:X8} — {2}",
|
||||
address, comEx.ErrorCode, comEx.Message);
|
||||
Log.Error(comEx, "COM write error for {Address}: HRESULT=0x{ErrorCode:X8}",
|
||||
address, comEx.ErrorCode);
|
||||
throw new InvalidOperationException(enriched, comEx);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
Log.Error(ex, "Failed to write value to {Address}", address);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Wait for OnWriteComplete callback (delivered via STA message pump)
|
||||
try
|
||||
{
|
||||
using (var cts = new CancellationTokenSource(_writeTimeoutMs))
|
||||
using (ct.Register(() => cts.Cancel()))
|
||||
{
|
||||
cts.Token.Register(() => tcs.TrySetResult(true)); // timeout = assume success (fire-and-forget fallback)
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Step 3: Clean up on the STA thread
|
||||
if (itemHandle > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_pendingWrites.Remove(itemHandle);
|
||||
if (_lmxProxy != null && _connectionHandle > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error cleaning up write item for {Address} (handle={Handle})", address, itemHandle);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Debug(ex, "Error dispatching write cleanup for {Address}", address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps an MxAccess OPC DA quality integer to the domain Quality enum.
|
||||
/// </summary>
|
||||
private static Quality MapQuality(int opcDaQuality)
|
||||
{
|
||||
if (Enum.IsDefined(typeof(Quality), (byte)opcDaQuality))
|
||||
return (Quality)(byte)opcDaQuality;
|
||||
|
||||
// Fallback: use category bits
|
||||
if (opcDaQuality >= 192) return Quality.Good;
|
||||
if (opcDaQuality >= 64) return Quality.Uncertain;
|
||||
return Quality.Bad;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
public sealed partial class MxAccessClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes to value changes for the specified addresses.
|
||||
/// Stores subscription state for reconnect replay.
|
||||
/// COM calls dispatched on the dedicated STA thread.
|
||||
/// </summary>
|
||||
public async Task<IAsyncDisposable> SubscribeAsync(
|
||||
IEnumerable<string> addresses,
|
||||
Action<string, Vtq> callback,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (!IsConnected)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
var addressList = addresses.ToList();
|
||||
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (!IsConnected || _lmxProxy == null)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
foreach (var address in addressList)
|
||||
{
|
||||
SubscribeInternal(address);
|
||||
|
||||
// Store for reconnect replay (but don't overwrite the probe tag's callback)
|
||||
if (_probeTestTagAddress == null ||
|
||||
!string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_storedSubscriptions[address] = callback;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Log.Information("Subscribed to {Count} tags", addressList.Count);
|
||||
|
||||
return new SubscriptionHandle(this, addressList, callback);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes specific addresses by address name.
|
||||
/// Removes from both COM state and stored subscriptions (no reconnect replay).
|
||||
/// </summary>
|
||||
public async Task UnsubscribeByAddressAsync(IEnumerable<string> addresses)
|
||||
{
|
||||
await UnsubscribeAsync(addresses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unsubscribes specific addresses.
|
||||
/// </summary>
|
||||
internal async Task UnsubscribeAsync(IEnumerable<string> addresses)
|
||||
{
|
||||
var addressList = addresses.ToList();
|
||||
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var address in addressList)
|
||||
{
|
||||
UnsubscribeInternal(address);
|
||||
|
||||
// Don't remove probe tag from stored subscriptions — it's permanent
|
||||
if (_probeTestTagAddress == null ||
|
||||
!string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_storedSubscriptions.Remove(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Log.Information("Unsubscribed from {Count} tags", addressList.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recreates all stored subscriptions after a reconnect.
|
||||
/// Does not re-store them (they're already stored).
|
||||
/// </summary>
|
||||
private async Task RecreateStoredSubscriptionsAsync()
|
||||
{
|
||||
Dictionary<string, Action<string, Vtq>> subscriptions;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_storedSubscriptions.Count == 0) return;
|
||||
subscriptions = new Dictionary<string, Action<string, Vtq>>(_storedSubscriptions);
|
||||
}
|
||||
|
||||
Log.Information("Recreating {Count} stored subscriptions after reconnect", subscriptions.Count);
|
||||
|
||||
await _staThread.RunAsync(() =>
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
foreach (var kvp in subscriptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
SubscribeInternal(kvp.Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Failed to recreate subscription for {Address}", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Internal COM calls ──────────
|
||||
|
||||
/// <summary>
|
||||
/// Registers a tag subscription with MxAccess COM API (AddItem + AdviseSupervisory).
|
||||
/// Must be called while holding _lock.
|
||||
/// </summary>
|
||||
private void SubscribeInternal(string address)
|
||||
{
|
||||
if (_lmxProxy == null || _connectionHandle <= 0)
|
||||
throw new InvalidOperationException("Not connected to MxAccess");
|
||||
|
||||
// If already subscribed to this address, skip
|
||||
if (_addressToHandle.ContainsKey(address))
|
||||
{
|
||||
Log.Debug("Already subscribed to {Address}, skipping", address);
|
||||
return;
|
||||
}
|
||||
|
||||
// Add the item to MxAccess
|
||||
int itemHandle = _lmxProxy.AddItem(_connectionHandle, address);
|
||||
|
||||
// Track handle-to-address and address-to-handle mappings
|
||||
_handleToAddress[itemHandle] = address;
|
||||
_addressToHandle[address] = itemHandle;
|
||||
|
||||
// Advise (subscribe) for data change events
|
||||
_lmxProxy.AdviseSupervisory(_connectionHandle, itemHandle);
|
||||
|
||||
Log.Debug("Subscribed to {Address} with handle {Handle}", address, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a tag subscription from MxAccess COM API (UnAdvise + RemoveItem).
|
||||
/// Must be called while holding _lock.
|
||||
/// </summary>
|
||||
private void UnsubscribeInternal(string address)
|
||||
{
|
||||
// Never unsubscribe the probe tag — it's a permanent connection health monitor
|
||||
if (_probeTestTagAddress != null &&
|
||||
string.Equals(address, _probeTestTagAddress, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Log.Debug("Skipping unsubscribe for probe tag {Address}", address);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_addressToHandle.TryGetValue(address, out int itemHandle))
|
||||
{
|
||||
Log.Debug("No active subscription for {Address}, skipping unsubscribe", address);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (_lmxProxy != null && _connectionHandle > 0)
|
||||
{
|
||||
_lmxProxy.UnAdvise(_connectionHandle, itemHandle);
|
||||
_lmxProxy.RemoveItem(_connectionHandle, itemHandle);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error unsubscribing from {Address} (handle {Handle})", address, itemHandle);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_handleToAddress.Remove(itemHandle);
|
||||
_addressToHandle.Remove(address);
|
||||
}
|
||||
|
||||
Log.Debug("Unsubscribed from {Address} (handle {Handle})", address, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposable subscription handle that unsubscribes on disposal.
|
||||
/// </summary>
|
||||
private sealed class SubscriptionHandle : IAsyncDisposable
|
||||
{
|
||||
private readonly MxAccessClient _client;
|
||||
private readonly List<string> _addresses;
|
||||
private readonly Action<string, Vtq> _callback;
|
||||
private bool _disposed;
|
||||
|
||||
public SubscriptionHandle(MxAccessClient client, List<string> addresses, Action<string, Vtq> callback)
|
||||
{
|
||||
_client = client;
|
||||
_addresses = addresses;
|
||||
_callback = callback;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
await _client.UnsubscribeAsync(_addresses);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ArchestrA.MxAccess;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Wraps the ArchestrA MXAccess COM API. All COM operations
|
||||
/// execute on a dedicated STA thread with a Windows message pump
|
||||
/// so that COM callbacks (OnDataChange, OnWriteComplete) are
|
||||
/// delivered correctly.
|
||||
/// </summary>
|
||||
public sealed partial class MxAccessClient : IScadaClient
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<MxAccessClient>();
|
||||
|
||||
private readonly object _lock = new object();
|
||||
private readonly int _maxConcurrentOperations;
|
||||
private readonly int _readTimeoutMs;
|
||||
private readonly int _writeTimeoutMs;
|
||||
private readonly int _monitorIntervalMs;
|
||||
private readonly bool _autoReconnect;
|
||||
private readonly string? _nodeName;
|
||||
private readonly string? _galaxyName;
|
||||
private readonly string _clientName;
|
||||
|
||||
private readonly SemaphoreSlim _readSemaphore;
|
||||
private readonly SemaphoreSlim _writeSemaphore;
|
||||
|
||||
// STA thread for COM interop
|
||||
private readonly StaComThread _staThread;
|
||||
|
||||
// COM objects — only accessed on the STA thread
|
||||
private LMXProxyServer? _lmxProxy;
|
||||
private int _connectionHandle;
|
||||
|
||||
// State
|
||||
private ConnectionState _connectionState = ConnectionState.Disconnected;
|
||||
private DateTime _connectedSince;
|
||||
private bool _disposed;
|
||||
|
||||
// Reconnect
|
||||
private CancellationTokenSource? _reconnectCts;
|
||||
|
||||
// Probe configuration
|
||||
private readonly string? _probeTestTagAddress;
|
||||
private readonly int _probeStaleThresholdMs;
|
||||
|
||||
// Probe state — updated by OnDataChange callback, read by monitor loop
|
||||
private DateTime _lastProbeValueTime;
|
||||
|
||||
// Reconnect counter
|
||||
private int _reconnectCount;
|
||||
|
||||
// Stored subscriptions for reconnect replay
|
||||
private readonly Dictionary<string, Action<string, Vtq>> _storedSubscriptions
|
||||
= new Dictionary<string, Action<string, Vtq>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Handle-to-address mapping for resolving COM callbacks
|
||||
private readonly Dictionary<int, string> _handleToAddress = new Dictionary<int, string>();
|
||||
|
||||
// Address-to-handle mapping for unsubscribe by address
|
||||
private readonly Dictionary<string, int> _addressToHandle
|
||||
= new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Pending write operations tracked by item handle
|
||||
private readonly Dictionary<int, TaskCompletionSource<bool>> _pendingWrites
|
||||
= new Dictionary<int, TaskCompletionSource<bool>>();
|
||||
|
||||
public MxAccessClient(
|
||||
int maxConcurrentOperations = 10,
|
||||
int readTimeoutSeconds = 5,
|
||||
int writeTimeoutSeconds = 5,
|
||||
int monitorIntervalSeconds = 5,
|
||||
bool autoReconnect = true,
|
||||
string? nodeName = null,
|
||||
string? galaxyName = null,
|
||||
string? probeTestTagAddress = null,
|
||||
int probeStaleThresholdMs = 5000,
|
||||
string? clientName = null)
|
||||
{
|
||||
_maxConcurrentOperations = maxConcurrentOperations;
|
||||
_readTimeoutMs = readTimeoutSeconds * 1000;
|
||||
_writeTimeoutMs = writeTimeoutSeconds * 1000;
|
||||
_monitorIntervalMs = monitorIntervalSeconds * 1000;
|
||||
_autoReconnect = autoReconnect;
|
||||
_nodeName = nodeName;
|
||||
_galaxyName = galaxyName;
|
||||
_probeTestTagAddress = probeTestTagAddress;
|
||||
_probeStaleThresholdMs = probeStaleThresholdMs;
|
||||
_clientName = clientName ?? "LmxProxy-" + Guid.NewGuid().ToString("N").Substring(0, 8);
|
||||
|
||||
_readSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
|
||||
_writeSemaphore = new SemaphoreSlim(maxConcurrentOperations, maxConcurrentOperations);
|
||||
|
||||
_staThread = new StaComThread();
|
||||
_staThread.Start();
|
||||
}
|
||||
|
||||
public bool IsConnected
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
return _lmxProxy != null
|
||||
&& _connectionState == ConnectionState.Connected
|
||||
&& _connectionHandle > 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public ConnectionState ConnectionState
|
||||
{
|
||||
get { lock (_lock) { return _connectionState; } }
|
||||
}
|
||||
|
||||
public event EventHandler<ConnectionStateChangedEventArgs>? ConnectionStateChanged;
|
||||
|
||||
private void SetState(ConnectionState newState, string? message = null)
|
||||
{
|
||||
ConnectionState previousState;
|
||||
lock (_lock)
|
||||
{
|
||||
previousState = _connectionState;
|
||||
_connectionState = newState;
|
||||
}
|
||||
|
||||
if (previousState != newState)
|
||||
{
|
||||
Log.Information("Connection state changed: {Previous} -> {Current} {Message}",
|
||||
previousState, newState, message ?? "");
|
||||
ConnectionStateChanged?.Invoke(this,
|
||||
new ConnectionStateChangedEventArgs(previousState, newState, message));
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_reconnectCts?.Cancel();
|
||||
|
||||
try
|
||||
{
|
||||
await DisconnectAsync();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error during disposal disconnect");
|
||||
}
|
||||
|
||||
_readSemaphore.Dispose();
|
||||
_writeSemaphore.Dispose();
|
||||
_reconnectCts?.Dispose();
|
||||
_staThread.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.MxAccess
|
||||
{
|
||||
/// <summary>
|
||||
/// Dedicated STA thread with a raw Win32 message pump for COM interop.
|
||||
/// All MxAccess COM objects must be created and called on this thread
|
||||
/// so that COM callbacks (OnDataChange, OnWriteComplete) are delivered
|
||||
/// via the message loop.
|
||||
/// </summary>
|
||||
public sealed class StaComThread : IDisposable
|
||||
{
|
||||
private const uint WM_APP = 0x8000;
|
||||
private const uint PM_NOREMOVE = 0x0000;
|
||||
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<StaComThread>();
|
||||
private static readonly TimeSpan PumpLogInterval = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly Thread _thread;
|
||||
private readonly TaskCompletionSource<bool> _ready = new TaskCompletionSource<bool>();
|
||||
private readonly ConcurrentQueue<Action> _workItems = new ConcurrentQueue<Action>();
|
||||
private volatile uint _nativeThreadId;
|
||||
private bool _disposed;
|
||||
|
||||
private long _totalMessages;
|
||||
private long _appMessages;
|
||||
private long _dispatchedMessages;
|
||||
private long _workItemsExecuted;
|
||||
private DateTime _lastLogTime;
|
||||
|
||||
public StaComThread()
|
||||
{
|
||||
_thread = new Thread(ThreadEntry)
|
||||
{
|
||||
Name = "MxAccess-STA",
|
||||
IsBackground = true
|
||||
};
|
||||
_thread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the STA thread and waits until the message pump is running.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
_thread.Start();
|
||||
_ready.Task.GetAwaiter().GetResult();
|
||||
Log.Information("STA COM thread started (ThreadId={ThreadId})", _thread.ManagedThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals a synchronous action onto the STA thread and returns a Task
|
||||
/// that completes when the action finishes.
|
||||
/// </summary>
|
||||
public Task RunAsync(Action action)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
_workItems.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
action();
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals a synchronous function onto the STA thread and returns
|
||||
/// a Task<T> with the result.
|
||||
/// </summary>
|
||||
public Task<T> RunAsync<T>(Func<T> func)
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(StaComThread));
|
||||
|
||||
var tcs = new TaskCompletionSource<T>();
|
||||
_workItems.Enqueue(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
tcs.TrySetResult(func());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
PostThreadMessage(_nativeThreadId, WM_APP, IntPtr.Zero, IntPtr.Zero);
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
if (_nativeThreadId != 0)
|
||||
PostThreadMessage(_nativeThreadId, WM_APP + 1, IntPtr.Zero, IntPtr.Zero);
|
||||
_thread.Join(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error shutting down STA COM thread");
|
||||
}
|
||||
|
||||
Log.Information("STA COM thread stopped");
|
||||
}
|
||||
|
||||
private void ThreadEntry()
|
||||
{
|
||||
try
|
||||
{
|
||||
_nativeThreadId = GetCurrentThreadId();
|
||||
|
||||
// Force message queue creation by peeking
|
||||
MSG msg;
|
||||
PeekMessage(out msg, IntPtr.Zero, 0, 0, PM_NOREMOVE);
|
||||
|
||||
_ready.TrySetResult(true);
|
||||
_lastLogTime = DateTime.UtcNow;
|
||||
|
||||
Log.Debug("STA message pump entering loop");
|
||||
|
||||
// Run the message loop — blocks until WM_QUIT
|
||||
while (GetMessage(out msg, IntPtr.Zero, 0, 0) > 0)
|
||||
{
|
||||
_totalMessages++;
|
||||
|
||||
if (msg.message == WM_APP)
|
||||
{
|
||||
_appMessages++;
|
||||
DrainQueue();
|
||||
}
|
||||
else if (msg.message == WM_APP + 1)
|
||||
{
|
||||
// Shutdown signal — drain remaining work then quit
|
||||
DrainQueue();
|
||||
PostQuitMessage(0);
|
||||
}
|
||||
else
|
||||
{
|
||||
_dispatchedMessages++;
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessage(ref msg);
|
||||
}
|
||||
|
||||
LogPumpStatsIfDue();
|
||||
}
|
||||
|
||||
Log.Information("STA message pump exited loop (Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems})",
|
||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "STA COM thread crashed");
|
||||
_ready.TrySetException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void DrainQueue()
|
||||
{
|
||||
while (_workItems.TryDequeue(out var workItem))
|
||||
{
|
||||
_workItemsExecuted++;
|
||||
try
|
||||
{
|
||||
workItem();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Unhandled exception in STA work item");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void LogPumpStatsIfDue()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (now - _lastLogTime < PumpLogInterval) return;
|
||||
|
||||
Log.Debug("STA pump alive: Total={Total}, App={App}, Dispatched={Dispatched}, WorkItems={WorkItems}, Pending={Pending}",
|
||||
_totalMessages, _appMessages, _dispatchedMessages, _workItemsExecuted, _workItems.Count);
|
||||
_lastLogTime = now;
|
||||
}
|
||||
|
||||
#region Win32 PInvoke
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct MSG
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint message;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public POINT pt;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct POINT
|
||||
{
|
||||
public int x;
|
||||
public int y;
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern int GetMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool TranslateMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref MSG lpMsg);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PostThreadMessage(uint idThread, uint Msg, IntPtr wParam, IntPtr lParam);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern void PostQuitMessage(int nExitCode);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool PeekMessage(out MSG lpMsg, IntPtr hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg);
|
||||
|
||||
[DllImport("kernel32.dll")]
|
||||
private static extern uint GetCurrentThreadId();
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
83
deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs
Normal file
83
deprecated/lmxproxy/src/ZB.MOM.WW.LmxProxy.Host/Program.cs
Normal file
@@ -0,0 +1,83 @@
|
||||
using System;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Serilog;
|
||||
using Topshelf;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
static int Main(string[] args)
|
||||
{
|
||||
// 1. Build configuration (instance override file loaded from LMXPROXY_INSTANCE env var)
|
||||
var instance = Environment.GetEnvironmentVariable("LMXPROXY_INSTANCE");
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.SetBasePath(AppDomain.CurrentDomain.BaseDirectory)
|
||||
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
|
||||
.AddJsonFile($"appsettings.{instance}.json", optional: true, reloadOnChange: false)
|
||||
.AddEnvironmentVariables()
|
||||
.Build();
|
||||
|
||||
// 2. Set working directory to exe location so relative log paths resolve correctly
|
||||
Environment.CurrentDirectory = AppDomain.CurrentDomain.BaseDirectory;
|
||||
|
||||
// 3. Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.Enrich.WithMachineName()
|
||||
.Enrich.WithThreadId()
|
||||
.CreateLogger();
|
||||
|
||||
try
|
||||
{
|
||||
// 4. Bind configuration
|
||||
var config = new LmxProxyConfiguration();
|
||||
configuration.Bind(config);
|
||||
|
||||
// 5. Configure Topshelf
|
||||
var exitCode = HostFactory.Run(host =>
|
||||
{
|
||||
host.UseSerilog();
|
||||
|
||||
host.Service<LmxProxyService>(service =>
|
||||
{
|
||||
service.ConstructUsing(() => new LmxProxyService(config));
|
||||
service.WhenStarted(s => s.Start());
|
||||
service.WhenStopped(s => s.Stop());
|
||||
service.WhenPaused(s => s.Pause());
|
||||
service.WhenContinued(s => s.Continue());
|
||||
service.WhenShutdown(s => s.Stop());
|
||||
});
|
||||
|
||||
host.SetServiceName("ZB.MOM.WW.LmxProxy.Host");
|
||||
host.SetDisplayName("SCADA Bridge LMX Proxy");
|
||||
host.SetDescription("gRPC proxy for AVEVA System Platform via MXAccess COM API");
|
||||
|
||||
host.StartAutomatically();
|
||||
host.EnablePauseAndContinue();
|
||||
|
||||
host.EnableServiceRecovery(recovery =>
|
||||
{
|
||||
recovery.RestartService(config.ServiceRecovery.FirstFailureDelayMinutes);
|
||||
recovery.RestartService(config.ServiceRecovery.SecondFailureDelayMinutes);
|
||||
recovery.RestartService(config.ServiceRecovery.SubsequentFailureDelayMinutes);
|
||||
recovery.SetResetPeriod(config.ServiceRecovery.ResetPeriodDays);
|
||||
});
|
||||
});
|
||||
|
||||
return (int)exitCode;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "LmxProxy service terminated unexpectedly");
|
||||
return 1;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>An API key with description, role, and enabled state.</summary>
|
||||
public class ApiKey
|
||||
{
|
||||
public string Key { get; set; } = string.Empty;
|
||||
public string Description { get; set; } = string.Empty;
|
||||
public ApiKeyRole Role { get; set; } = ApiKeyRole.ReadOnly;
|
||||
public bool Enabled { get; set; } = true;
|
||||
}
|
||||
|
||||
/// <summary>API key role for authorization.</summary>
|
||||
public enum ApiKeyRole
|
||||
{
|
||||
/// <summary>Read and subscribe only.</summary>
|
||||
ReadOnly,
|
||||
/// <summary>Full access including writes.</summary>
|
||||
ReadWrite
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>JSON structure for the API key configuration file.</summary>
|
||||
public class ApiKeyConfiguration
|
||||
{
|
||||
public List<ApiKey> ApiKeys { get; set; } = new List<ApiKey>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using Grpc.Core;
|
||||
using Grpc.Core.Interceptors;
|
||||
using GrpcStatus = Grpc.Core.Status;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// gRPC server interceptor that enforces API key authentication and role-based authorization.
|
||||
/// Extracts x-api-key from metadata, validates via ApiKeyService, enforces ReadWrite for writes.
|
||||
/// </summary>
|
||||
public class ApiKeyInterceptor : Interceptor
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<ApiKeyInterceptor>();
|
||||
|
||||
private readonly ApiKeyService _apiKeyService;
|
||||
|
||||
/// <summary>RPC method names that require the ReadWrite role.</summary>
|
||||
private static readonly HashSet<string> WriteProtectedMethods = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"/scada.ScadaService/Write",
|
||||
"/scada.ScadaService/WriteBatch",
|
||||
"/scada.ScadaService/WriteBatchAndWait"
|
||||
};
|
||||
|
||||
public ApiKeyInterceptor(ApiKeyService apiKeyService)
|
||||
{
|
||||
_apiKeyService = apiKeyService;
|
||||
}
|
||||
|
||||
public override async Task<TResponse> UnaryServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
ServerCallContext context,
|
||||
UnaryServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
ValidateApiKey(context);
|
||||
return await continuation(request, context);
|
||||
}
|
||||
|
||||
public override async Task ServerStreamingServerHandler<TRequest, TResponse>(
|
||||
TRequest request,
|
||||
IServerStreamWriter<TResponse> responseStream,
|
||||
ServerCallContext context,
|
||||
ServerStreamingServerMethod<TRequest, TResponse> continuation)
|
||||
{
|
||||
ValidateApiKey(context);
|
||||
await continuation(request, responseStream, context);
|
||||
}
|
||||
|
||||
private void ValidateApiKey(ServerCallContext context)
|
||||
{
|
||||
// Extract x-api-key from metadata
|
||||
var apiKeyEntry = context.RequestHeaders.Get("x-api-key");
|
||||
var apiKey = apiKeyEntry?.Value ?? string.Empty;
|
||||
|
||||
if (string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
Log.Warning("Request rejected: missing x-api-key header for {Method}", context.Method);
|
||||
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Missing x-api-key header"));
|
||||
}
|
||||
|
||||
var key = _apiKeyService.ValidateApiKey(apiKey);
|
||||
if (key == null)
|
||||
{
|
||||
Log.Warning("Request rejected: invalid API key for {Method}", context.Method);
|
||||
throw new RpcException(new GrpcStatus(StatusCode.Unauthenticated, "Invalid API key"));
|
||||
}
|
||||
|
||||
// Check write authorization
|
||||
if (WriteProtectedMethods.Contains(context.Method) && key.Role != ApiKeyRole.ReadWrite)
|
||||
{
|
||||
Log.Warning("Request rejected: ReadOnly key attempted write operation {Method}", context.Method);
|
||||
throw new RpcException(new GrpcStatus(StatusCode.PermissionDenied,
|
||||
"Write operations require a ReadWrite API key"));
|
||||
}
|
||||
|
||||
// Store the validated key in UserState for downstream use
|
||||
context.UserState["ApiKey"] = key;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Newtonsoft.Json;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages API keys loaded from a JSON file with hot-reload via FileSystemWatcher.
|
||||
/// </summary>
|
||||
public sealed class ApiKeyService : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<ApiKeyService>();
|
||||
|
||||
private readonly string _configFilePath;
|
||||
private readonly FileSystemWatcher? _watcher;
|
||||
private readonly SemaphoreSlim _reloadLock = new SemaphoreSlim(1, 1);
|
||||
private volatile Dictionary<string, ApiKey> _keys = new Dictionary<string, ApiKey>(StringComparer.Ordinal);
|
||||
private DateTime _lastReloadTime = DateTime.MinValue;
|
||||
private static readonly TimeSpan DebounceInterval = TimeSpan.FromSeconds(1);
|
||||
|
||||
public ApiKeyService(string configFilePath)
|
||||
{
|
||||
_configFilePath = Path.GetFullPath(configFilePath);
|
||||
|
||||
// Auto-generate default file if missing
|
||||
if (!File.Exists(_configFilePath))
|
||||
{
|
||||
GenerateDefaultKeyFile();
|
||||
}
|
||||
|
||||
// Initial load
|
||||
LoadKeys();
|
||||
|
||||
// Set up FileSystemWatcher for hot-reload
|
||||
var directory = Path.GetDirectoryName(_configFilePath);
|
||||
var fileName = Path.GetFileName(_configFilePath);
|
||||
if (directory != null)
|
||||
{
|
||||
_watcher = new FileSystemWatcher(directory, fileName)
|
||||
{
|
||||
NotifyFilter = NotifyFilters.LastWrite | NotifyFilters.Size,
|
||||
EnableRaisingEvents = true
|
||||
};
|
||||
_watcher.Changed += OnFileChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates an API key. Returns the ApiKey if valid and enabled, null otherwise.
|
||||
/// </summary>
|
||||
public ApiKey? ValidateApiKey(string apiKey)
|
||||
{
|
||||
if (string.IsNullOrEmpty(apiKey)) return null;
|
||||
return _keys.TryGetValue(apiKey, out var key) && key.Enabled ? key : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a key has the required role.
|
||||
/// ReadWrite implies ReadOnly.
|
||||
/// </summary>
|
||||
public bool HasRole(string apiKey, ApiKeyRole requiredRole)
|
||||
{
|
||||
var key = ValidateApiKey(apiKey);
|
||||
if (key == null) return false;
|
||||
|
||||
switch (requiredRole)
|
||||
{
|
||||
case ApiKeyRole.ReadOnly:
|
||||
return true; // Both roles have ReadOnly
|
||||
case ApiKeyRole.ReadWrite:
|
||||
return key.Role == ApiKeyRole.ReadWrite;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the count of loaded API keys.</summary>
|
||||
public int KeyCount => _keys.Count;
|
||||
|
||||
private void GenerateDefaultKeyFile()
|
||||
{
|
||||
Log.Information("API key file not found at {Path}, generating defaults", _configFilePath);
|
||||
|
||||
var config = new ApiKeyConfiguration
|
||||
{
|
||||
ApiKeys = new List<ApiKey>
|
||||
{
|
||||
new ApiKey
|
||||
{
|
||||
Key = GenerateRandomKey(),
|
||||
Description = "Default ReadOnly key (auto-generated)",
|
||||
Role = ApiKeyRole.ReadOnly,
|
||||
Enabled = true
|
||||
},
|
||||
new ApiKey
|
||||
{
|
||||
Key = GenerateRandomKey(),
|
||||
Description = "Default ReadWrite key (auto-generated)",
|
||||
Role = ApiKeyRole.ReadWrite,
|
||||
Enabled = true
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var directory = Path.GetDirectoryName(_configFilePath);
|
||||
if (!string.IsNullOrEmpty(directory) && !Directory.Exists(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
var json = JsonConvert.SerializeObject(config, Formatting.Indented);
|
||||
File.WriteAllText(_configFilePath, json);
|
||||
Log.Information("Default API key file generated at {Path}", _configFilePath);
|
||||
}
|
||||
|
||||
private static string GenerateRandomKey()
|
||||
{
|
||||
// 32 random bytes -> 64-char hex string
|
||||
var bytes = new byte[32];
|
||||
using (var rng = System.Security.Cryptography.RandomNumberGenerator.Create())
|
||||
{
|
||||
rng.GetBytes(bytes);
|
||||
}
|
||||
return BitConverter.ToString(bytes).Replace("-", "").ToLowerInvariant();
|
||||
}
|
||||
|
||||
private void LoadKeys()
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(_configFilePath);
|
||||
var config = JsonConvert.DeserializeObject<ApiKeyConfiguration>(json);
|
||||
if (config?.ApiKeys != null)
|
||||
{
|
||||
_keys = config.ApiKeys
|
||||
.Where(k => !string.IsNullOrEmpty(k.Key))
|
||||
.ToDictionary(k => k.Key, k => k, StringComparer.Ordinal);
|
||||
Log.Information("Loaded {Count} API keys from {Path}", _keys.Count, _configFilePath);
|
||||
}
|
||||
else
|
||||
{
|
||||
Log.Warning("API key file at {Path} contained no keys", _configFilePath);
|
||||
_keys = new Dictionary<string, ApiKey>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to load API keys from {Path}", _configFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnFileChanged(object sender, FileSystemEventArgs e)
|
||||
{
|
||||
// Debounce: ignore rapid changes within 1 second
|
||||
if (DateTime.UtcNow - _lastReloadTime < DebounceInterval) return;
|
||||
|
||||
if (_reloadLock.Wait(0))
|
||||
{
|
||||
try
|
||||
{
|
||||
_lastReloadTime = DateTime.UtcNow;
|
||||
Log.Information("API key file changed, reloading");
|
||||
|
||||
// Small delay to let the file system finish writing
|
||||
Thread.Sleep(100);
|
||||
LoadKeys();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_reloadLock.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_watcher?.Dispose();
|
||||
_reloadLock.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
using System.IO;
|
||||
using Grpc.Core;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Security
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages TLS certificates for the gRPC server.
|
||||
/// If TLS is enabled but certs are missing, logs a warning (self-signed generation
|
||||
/// would be added as a future enhancement, or done manually).
|
||||
/// </summary>
|
||||
public static class TlsCertificateManager
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext(typeof(TlsCertificateManager));
|
||||
|
||||
/// <summary>
|
||||
/// Creates gRPC server credentials based on TLS configuration.
|
||||
/// Returns InsecureServerCredentials if TLS is disabled.
|
||||
/// </summary>
|
||||
public static ServerCredentials CreateServerCredentials(TlsConfiguration config)
|
||||
{
|
||||
if (!config.Enabled)
|
||||
{
|
||||
Log.Information("TLS disabled, using insecure server credentials");
|
||||
return ServerCredentials.Insecure;
|
||||
}
|
||||
|
||||
if (!File.Exists(config.ServerCertificatePath) || !File.Exists(config.ServerKeyPath))
|
||||
{
|
||||
Log.Warning("TLS enabled but certificate files not found. Falling back to insecure credentials. " +
|
||||
"Cert: {CertPath}, Key: {KeyPath}",
|
||||
config.ServerCertificatePath, config.ServerKeyPath);
|
||||
return ServerCredentials.Insecure;
|
||||
}
|
||||
|
||||
var certChain = File.ReadAllText(config.ServerCertificatePath);
|
||||
var privateKey = File.ReadAllText(config.ServerKeyPath);
|
||||
|
||||
var keyCertPair = new KeyCertificatePair(certChain, privateKey);
|
||||
|
||||
if (config.RequireClientCertificate && File.Exists(config.ClientCaCertificatePath))
|
||||
{
|
||||
var caCert = File.ReadAllText(config.ClientCaCertificatePath);
|
||||
Log.Information("TLS enabled with mutual TLS (client certificate required)");
|
||||
return new SslServerCredentials(
|
||||
new[] { keyCertPair },
|
||||
caCert,
|
||||
SslClientCertificateRequestType.RequestAndRequireAndVerify);
|
||||
}
|
||||
|
||||
Log.Information("TLS enabled (server-only)");
|
||||
return new SslServerCredentials(new[] { keyCertPair });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Sessions
|
||||
{
|
||||
/// <summary>
|
||||
/// Tracks active client sessions in memory.
|
||||
/// Thread-safe via ConcurrentDictionary.
|
||||
/// </summary>
|
||||
public sealed class SessionManager : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<SessionManager>();
|
||||
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions
|
||||
= new ConcurrentDictionary<string, SessionInfo>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly Timer? _scavengingTimer;
|
||||
private readonly TimeSpan _inactivityTimeout;
|
||||
private Action<string>? _onSessionScavenged;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a SessionManager with optional inactivity scavenging.
|
||||
/// </summary>
|
||||
/// <param name="inactivityTimeoutMinutes">
|
||||
/// Sessions inactive for this many minutes are automatically terminated.
|
||||
/// Set to 0 to disable scavenging.
|
||||
/// </param>
|
||||
public SessionManager(int inactivityTimeoutMinutes = 5)
|
||||
{
|
||||
_inactivityTimeout = TimeSpan.FromMinutes(inactivityTimeoutMinutes);
|
||||
|
||||
if (inactivityTimeoutMinutes > 0)
|
||||
{
|
||||
// Check every 60 seconds
|
||||
_scavengingTimer = new Timer(ScavengeInactiveSessions, null,
|
||||
TimeSpan.FromSeconds(60), TimeSpan.FromSeconds(60));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a callback invoked when a session is scavenged due to inactivity.
|
||||
/// The callback receives the session ID.
|
||||
/// </summary>
|
||||
public void OnSessionScavenged(Action<string> callback)
|
||||
{
|
||||
_onSessionScavenged = callback;
|
||||
}
|
||||
|
||||
/// <summary>Gets the count of active sessions.</summary>
|
||||
public int ActiveSessionCount => _sessions.Count;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new session.
|
||||
/// Returns the 32-character hex GUID session ID.
|
||||
/// </summary>
|
||||
public string CreateSession(string clientId, string apiKey)
|
||||
{
|
||||
var sessionId = Guid.NewGuid().ToString("N"); // 32-char lowercase hex, no hyphens
|
||||
var sessionInfo = new SessionInfo(sessionId, clientId, apiKey);
|
||||
_sessions[sessionId] = sessionInfo;
|
||||
|
||||
Log.Information("Session created: {SessionId} for client {ClientId}", sessionId, clientId);
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates a session ID. Updates LastActivity on success.
|
||||
/// Returns true if the session exists.
|
||||
/// </summary>
|
||||
public bool ValidateSession(string sessionId)
|
||||
{
|
||||
if (_sessions.TryGetValue(sessionId, out var session))
|
||||
{
|
||||
session.TouchLastActivity();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Terminates a session. Returns true if the session existed.
|
||||
/// </summary>
|
||||
public bool TerminateSession(string sessionId)
|
||||
{
|
||||
if (_sessions.TryRemove(sessionId, out _))
|
||||
{
|
||||
Log.Information("Session terminated: {SessionId}", sessionId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Gets session info by ID, or null if not found.</summary>
|
||||
public SessionInfo? GetSession(string sessionId)
|
||||
{
|
||||
_sessions.TryGetValue(sessionId, out var session);
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>Gets a snapshot of all active sessions.</summary>
|
||||
public IReadOnlyList<SessionInfo> GetAllSessions()
|
||||
{
|
||||
return _sessions.Values.ToList().AsReadOnly();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Scavenges sessions that have been inactive for longer than the timeout.
|
||||
/// </summary>
|
||||
private void ScavengeInactiveSessions(object? state)
|
||||
{
|
||||
if (_inactivityTimeout <= TimeSpan.Zero) return;
|
||||
|
||||
var cutoff = DateTime.UtcNow - _inactivityTimeout;
|
||||
var expired = _sessions.Where(kvp => kvp.Value.LastActivity < cutoff).ToList();
|
||||
|
||||
foreach (var kvp in expired)
|
||||
{
|
||||
if (_sessions.TryRemove(kvp.Key, out _))
|
||||
{
|
||||
Log.Information("Session {SessionId} scavenged (inactive since {LastActivity})",
|
||||
kvp.Key, kvp.Value.LastActivity);
|
||||
|
||||
try
|
||||
{
|
||||
_onSessionScavenged?.Invoke(kvp.Key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error in session scavenge callback for {SessionId}", kvp.Key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_scavengingTimer?.Dispose();
|
||||
_sessions.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an active client session.
|
||||
/// </summary>
|
||||
public class SessionInfo
|
||||
{
|
||||
public SessionInfo(string sessionId, string clientId, string apiKey)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
ClientId = clientId;
|
||||
ApiKey = apiKey;
|
||||
ConnectedAt = DateTime.UtcNow;
|
||||
LastActivity = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public string SessionId { get; }
|
||||
public string ClientId { get; }
|
||||
public string ApiKey { get; }
|
||||
public DateTime ConnectedAt { get; }
|
||||
public DateTime LastActivity { get; private set; }
|
||||
public long ConnectedSinceUtcTicks => ConnectedAt.Ticks;
|
||||
|
||||
/// <summary>Updates the last activity timestamp to now.</summary>
|
||||
public void TouchLastActivity()
|
||||
{
|
||||
LastActivity = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Status
|
||||
{
|
||||
public class StatusData
|
||||
{
|
||||
public DateTime Timestamp { get; set; }
|
||||
public string ServiceName { get; set; } = "";
|
||||
public string Version { get; set; } = "";
|
||||
public ConnectionStatus Connection { get; set; } = new ConnectionStatus();
|
||||
public SubscriptionStatus Subscriptions { get; set; } = new SubscriptionStatus();
|
||||
public PerformanceStatus Performance { get; set; } = new PerformanceStatus();
|
||||
public HealthInfo Health { get; set; } = new HealthInfo();
|
||||
}
|
||||
|
||||
public class ConnectionStatus
|
||||
{
|
||||
public bool IsConnected { get; set; }
|
||||
public string State { get; set; } = "";
|
||||
public string NodeName { get; set; } = "";
|
||||
public string GalaxyName { get; set; } = "";
|
||||
public DateTime? ConnectedSince { get; set; }
|
||||
public int ReconnectCount { get; set; }
|
||||
}
|
||||
|
||||
public class SubscriptionStatus
|
||||
{
|
||||
public int TotalClients { get; set; }
|
||||
public int TotalTags { get; set; }
|
||||
public int ActiveSubscriptions { get; set; }
|
||||
public long TotalDelivered { get; set; }
|
||||
public long TotalDropped { get; set; }
|
||||
}
|
||||
|
||||
public class PerformanceStatus
|
||||
{
|
||||
public long TotalOperations { get; set; }
|
||||
public double AverageSuccessRate { get; set; }
|
||||
public Dictionary<string, OperationStatus> Operations { get; set; }
|
||||
= new Dictionary<string, OperationStatus>();
|
||||
}
|
||||
|
||||
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; }
|
||||
public double Percentile95Milliseconds { get; set; }
|
||||
}
|
||||
|
||||
public class HealthInfo
|
||||
{
|
||||
public string Status { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public Dictionary<string, string> Data { get; set; } = new Dictionary<string, string>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Serialization;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Domain;
|
||||
using HealthCheckService = ZB.MOM.WW.LmxProxy.Host.Health.HealthCheckService;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Metrics;
|
||||
using ZB.MOM.WW.LmxProxy.Host.Subscriptions;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// Aggregates health, metrics, and subscription data into status reports.
|
||||
/// </summary>
|
||||
public class StatusReportService
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<StatusReportService>();
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly SubscriptionManager _subscriptionManager;
|
||||
private readonly PerformanceMetrics _performanceMetrics;
|
||||
private readonly HealthCheckService _healthCheckService;
|
||||
|
||||
public StatusReportService(
|
||||
IScadaClient scadaClient,
|
||||
SubscriptionManager subscriptionManager,
|
||||
PerformanceMetrics performanceMetrics,
|
||||
HealthCheckService healthCheckService)
|
||||
{
|
||||
_scadaClient = scadaClient;
|
||||
_subscriptionManager = subscriptionManager;
|
||||
_performanceMetrics = performanceMetrics;
|
||||
_healthCheckService = healthCheckService;
|
||||
}
|
||||
|
||||
public async Task<string> GenerateHtmlReportAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var statusData = await CollectStatusDataAsync();
|
||||
return GenerateHtmlFromStatusData(statusData);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to generate HTML report");
|
||||
return GenerateErrorHtml(ex);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<string> GenerateJsonReportAsync()
|
||||
{
|
||||
var statusData = await CollectStatusDataAsync();
|
||||
var settings = new JsonSerializerSettings
|
||||
{
|
||||
Formatting = Formatting.Indented,
|
||||
ContractResolver = new CamelCasePropertyNamesContractResolver()
|
||||
};
|
||||
return JsonConvert.SerializeObject(statusData, settings);
|
||||
}
|
||||
|
||||
public async Task<bool> IsHealthyAsync()
|
||||
{
|
||||
var result = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
|
||||
return result.Status == HealthStatus.Healthy;
|
||||
}
|
||||
|
||||
private async Task<StatusData> CollectStatusDataAsync()
|
||||
{
|
||||
var statusData = new StatusData
|
||||
{
|
||||
Timestamp = DateTime.UtcNow,
|
||||
ServiceName = "ZB.MOM.WW.LmxProxy.Host",
|
||||
Version = Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? "0.0.0.0"
|
||||
};
|
||||
|
||||
// Connection info
|
||||
statusData.Connection = new ConnectionStatus
|
||||
{
|
||||
IsConnected = _scadaClient.IsConnected,
|
||||
State = _scadaClient.ConnectionState.ToString(),
|
||||
ConnectedSince = _scadaClient.IsConnected ? _scadaClient.ConnectedSince : (DateTime?)null,
|
||||
ReconnectCount = _scadaClient.ReconnectCount
|
||||
};
|
||||
|
||||
// Subscription stats
|
||||
var subStats = _subscriptionManager.GetStats();
|
||||
statusData.Subscriptions = new SubscriptionStatus
|
||||
{
|
||||
TotalClients = subStats.TotalClients,
|
||||
TotalTags = subStats.TotalTags,
|
||||
ActiveSubscriptions = subStats.ActiveSubscriptions,
|
||||
TotalDelivered = subStats.TotalDelivered,
|
||||
TotalDropped = subStats.TotalDropped
|
||||
};
|
||||
|
||||
// Performance stats
|
||||
var allStats = _performanceMetrics.GetStatistics();
|
||||
long totalOps = 0;
|
||||
double totalSuccessRate = 0;
|
||||
int opCount = 0;
|
||||
|
||||
foreach (var kvp in allStats)
|
||||
{
|
||||
totalOps += kvp.Value.TotalCount;
|
||||
totalSuccessRate += kvp.Value.SuccessRate;
|
||||
opCount++;
|
||||
|
||||
statusData.Performance.Operations[kvp.Key] = new OperationStatus
|
||||
{
|
||||
TotalCount = kvp.Value.TotalCount,
|
||||
SuccessRate = kvp.Value.SuccessRate,
|
||||
AverageMilliseconds = kvp.Value.AverageMilliseconds,
|
||||
MinMilliseconds = kvp.Value.MinMilliseconds,
|
||||
MaxMilliseconds = kvp.Value.MaxMilliseconds,
|
||||
Percentile95Milliseconds = kvp.Value.Percentile95Milliseconds
|
||||
};
|
||||
}
|
||||
|
||||
statusData.Performance.TotalOperations = totalOps;
|
||||
statusData.Performance.AverageSuccessRate = opCount > 0
|
||||
? totalSuccessRate / opCount
|
||||
: 1.0;
|
||||
|
||||
// Health check
|
||||
var healthResult = await _healthCheckService.CheckHealthAsync(new HealthCheckContext());
|
||||
statusData.Health = new HealthInfo
|
||||
{
|
||||
Status = healthResult.Status.ToString(),
|
||||
Description = healthResult.Description ?? ""
|
||||
};
|
||||
if (healthResult.Data != null)
|
||||
{
|
||||
foreach (var kvp in healthResult.Data)
|
||||
{
|
||||
statusData.Health.Data[kvp.Key] = kvp.Value?.ToString() ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
return statusData;
|
||||
}
|
||||
|
||||
private static string GenerateHtmlFromStatusData(StatusData statusData)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html lang=\"en\">");
|
||||
sb.AppendLine("<head>");
|
||||
sb.AppendLine(" <meta charset=\"utf-8\">");
|
||||
sb.AppendLine(" <meta http-equiv=\"refresh\" content=\"30\">");
|
||||
sb.AppendLine(" <title>LmxProxy Status</title>");
|
||||
sb.AppendLine(" <style>");
|
||||
sb.AppendLine(" body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 20px; background: #f5f5f5; }");
|
||||
sb.AppendLine(" h1 { color: #333; }");
|
||||
sb.AppendLine(" .card { background: white; border-radius: 4px; padding: 16px; margin-bottom: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.12); }");
|
||||
sb.AppendLine(" .card-green { border-left: 4px solid #28a745; }");
|
||||
sb.AppendLine(" .card-yellow { border-left: 4px solid #ffc107; }");
|
||||
sb.AppendLine(" .card-red { border-left: 4px solid #dc3545; }");
|
||||
sb.AppendLine(" .grid { display: flex; flex-wrap: wrap; gap: 16px; }");
|
||||
sb.AppendLine(" .grid-item { flex: 1; min-width: 300px; }");
|
||||
sb.AppendLine(" table { width: 100%; border-collapse: collapse; }");
|
||||
sb.AppendLine(" th, td { text-align: left; padding: 8px; border-bottom: 1px solid #eee; }");
|
||||
sb.AppendLine(" th { background: #f8f9fa; font-weight: 600; }");
|
||||
sb.AppendLine(" .status-healthy { color: #28a745; font-weight: bold; }");
|
||||
sb.AppendLine(" .status-degraded { color: #ffc107; font-weight: bold; }");
|
||||
sb.AppendLine(" .status-unhealthy { color: #dc3545; font-weight: bold; }");
|
||||
sb.AppendLine(" .footer { color: #999; font-size: 0.85em; margin-top: 20px; }");
|
||||
sb.AppendLine(" </style>");
|
||||
sb.AppendLine("</head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine(" <h1>LmxProxy Status Dashboard</h1>");
|
||||
|
||||
// Connection card
|
||||
var connClass = statusData.Connection.IsConnected ? "card-green" : "card-red";
|
||||
sb.AppendLine($" <div class=\"grid\">");
|
||||
sb.AppendLine($" <div class=\"grid-item\"><div class=\"card {connClass}\">");
|
||||
sb.AppendLine(" <h3>Connection</h3>");
|
||||
sb.AppendLine($" <p><strong>Connected:</strong> {statusData.Connection.IsConnected}</p>");
|
||||
sb.AppendLine($" <p><strong>State:</strong> {statusData.Connection.State}</p>");
|
||||
if (statusData.Connection.ConnectedSince.HasValue)
|
||||
sb.AppendLine($" <p><strong>Connected Since:</strong> {statusData.Connection.ConnectedSince.Value:yyyy-MM-dd HH:mm:ss} UTC</p>");
|
||||
if (statusData.Connection.ReconnectCount > 0)
|
||||
sb.AppendLine($" <p><strong>Reconnects:</strong> {statusData.Connection.ReconnectCount}</p>");
|
||||
if (!string.IsNullOrEmpty(statusData.Connection.NodeName))
|
||||
sb.AppendLine($" <p><strong>Node:</strong> {statusData.Connection.NodeName}</p>");
|
||||
if (!string.IsNullOrEmpty(statusData.Connection.GalaxyName))
|
||||
sb.AppendLine($" <p><strong>Galaxy:</strong> {statusData.Connection.GalaxyName}</p>");
|
||||
sb.AppendLine(" </div></div>");
|
||||
|
||||
// Health card
|
||||
var healthClass = GetHealthCardClass(statusData.Health.Status);
|
||||
var healthCss = GetHealthStatusCss(statusData.Health.Status);
|
||||
sb.AppendLine($" <div class=\"grid-item\"><div class=\"card {healthClass}\">");
|
||||
sb.AppendLine(" <h3>Health</h3>");
|
||||
sb.AppendLine($" <p class=\"{healthCss}\">{statusData.Health.Status}</p>");
|
||||
sb.AppendLine($" <p>{statusData.Health.Description}</p>");
|
||||
sb.AppendLine(" </div></div>");
|
||||
|
||||
// Subscriptions card
|
||||
var subCardCss = statusData.Subscriptions.TotalDropped > 0 ? "card-yellow" : "card-green";
|
||||
sb.AppendLine($" <div class=\"grid-item\"><div class=\"card {subCardCss}\">");
|
||||
sb.AppendLine(" <h3>Subscriptions</h3>");
|
||||
sb.AppendLine($" <p><strong>Clients:</strong> {statusData.Subscriptions.TotalClients}</p>");
|
||||
sb.AppendLine($" <p><strong>Tags:</strong> {statusData.Subscriptions.TotalTags}</p>");
|
||||
sb.AppendLine($" <p><strong>Active:</strong> {statusData.Subscriptions.ActiveSubscriptions}</p>");
|
||||
sb.AppendLine($" <p><strong>Delivered:</strong> {statusData.Subscriptions.TotalDelivered:N0}</p>");
|
||||
if (statusData.Subscriptions.TotalDropped > 0)
|
||||
{
|
||||
sb.AppendLine($" <p style=\"color:red\"><strong>Dropped:</strong> {statusData.Subscriptions.TotalDropped:N0}</p>");
|
||||
}
|
||||
sb.AppendLine(" </div></div>");
|
||||
sb.AppendLine(" </div>");
|
||||
|
||||
// RPC Operations table (always shown)
|
||||
sb.AppendLine(" <div class=\"card\">");
|
||||
sb.AppendLine(" <h3>RPC Operations</h3>");
|
||||
sb.AppendLine(" <table>");
|
||||
sb.AppendLine(" <tr><th>Operation</th><th>Count</th><th>Success Rate</th><th>Avg (ms)</th><th>Min (ms)</th><th>Max (ms)</th><th>P95 (ms)</th></tr>");
|
||||
|
||||
// All known RPC operations — show each even if 0 calls
|
||||
var rpcNames = new[] { "Read", "ReadBatch", "Write", "WriteBatch", "Subscribe" };
|
||||
foreach (var rpcName in rpcNames)
|
||||
{
|
||||
var key = rpcName.Substring(0, 1).ToLowerInvariant() + rpcName.Substring(1);
|
||||
if (statusData.Performance.Operations.TryGetValue(key, out var op))
|
||||
{
|
||||
sb.AppendLine($" <tr>" +
|
||||
$"<td>{rpcName}</td>" +
|
||||
$"<td>{op.TotalCount}</td>" +
|
||||
$"<td>{op.SuccessRate:P1}</td>" +
|
||||
$"<td>{op.AverageMilliseconds:F1}</td>" +
|
||||
$"<td>{op.MinMilliseconds:F1}</td>" +
|
||||
$"<td>{op.MaxMilliseconds:F1}</td>" +
|
||||
$"<td>{op.Percentile95Milliseconds:F1}</td>" +
|
||||
$"</tr>");
|
||||
}
|
||||
else
|
||||
{
|
||||
sb.AppendLine($" <tr><td>{rpcName}</td><td>0</td><td>—</td><td>—</td><td>—</td><td>—</td><td>—</td></tr>");
|
||||
}
|
||||
}
|
||||
|
||||
sb.AppendLine(" </table>");
|
||||
sb.AppendLine(" </div>");
|
||||
|
||||
sb.AppendLine($" <div class=\"footer\">Last updated: {statusData.Timestamp:yyyy-MM-dd HH:mm:ss} UTC | Service: {statusData.ServiceName} v{statusData.Version}</div>");
|
||||
sb.AppendLine("</body>");
|
||||
sb.AppendLine("</html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static string GetHealthCardClass(string status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case "Healthy": return "card-green";
|
||||
case "Degraded": return "card-yellow";
|
||||
default: return "card-red";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetHealthStatusCss(string status)
|
||||
{
|
||||
switch (status)
|
||||
{
|
||||
case "Healthy": return "status-healthy";
|
||||
case "Degraded": return "status-degraded";
|
||||
default: return "status-unhealthy";
|
||||
}
|
||||
}
|
||||
|
||||
private static string GenerateErrorHtml(Exception ex)
|
||||
{
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("<!DOCTYPE html>");
|
||||
sb.AppendLine("<html><head><title>LmxProxy Status - Error</title></head>");
|
||||
sb.AppendLine("<body>");
|
||||
sb.AppendLine("<h1>Error generating status report</h1>");
|
||||
sb.AppendLine($"<p>{ex.Message}</p>");
|
||||
sb.AppendLine("</body></html>");
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
using System;
|
||||
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.Status
|
||||
{
|
||||
/// <summary>
|
||||
/// HTTP status server providing an HTML dashboard, JSON API, and health endpoint.
|
||||
/// </summary>
|
||||
public class StatusWebServer : IDisposable
|
||||
{
|
||||
private static readonly ILogger Logger = Log.ForContext<StatusWebServer>();
|
||||
|
||||
private readonly WebServerConfiguration _configuration;
|
||||
private readonly StatusReportService _statusReportService;
|
||||
private HttpListener? _httpListener;
|
||||
private CancellationTokenSource? _cancellationTokenSource;
|
||||
private Task? _listenerTask;
|
||||
private bool _disposed;
|
||||
|
||||
public StatusWebServer(WebServerConfiguration configuration, StatusReportService statusReportService)
|
||||
{
|
||||
_configuration = configuration;
|
||||
_statusReportService = statusReportService;
|
||||
}
|
||||
|
||||
public bool Start()
|
||||
{
|
||||
if (!_configuration.Enabled)
|
||||
{
|
||||
Logger.Information("Status web server is disabled");
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_httpListener = new HttpListener();
|
||||
var 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 on {Prefix}", prefix);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Failed to start status web server");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public bool Stop()
|
||||
{
|
||||
if (!_configuration.Enabled || _httpListener == null)
|
||||
return true;
|
||||
|
||||
try
|
||||
{
|
||||
_cancellationTokenSource?.Cancel();
|
||||
|
||||
if (_listenerTask != null)
|
||||
{
|
||||
_listenerTask.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
_httpListener.Stop();
|
||||
_httpListener.Close();
|
||||
|
||||
Logger.Information("Status web server stopped");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error stopping status web server");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
Stop();
|
||||
_cancellationTokenSource?.Dispose();
|
||||
if (_httpListener != null)
|
||||
{
|
||||
((IDisposable)_httpListener).Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested && _httpListener != null && _httpListener.IsListening)
|
||||
{
|
||||
try
|
||||
{
|
||||
var context = await _httpListener.GetContextAsync();
|
||||
_ = Task.Run(() => HandleRequestAsync(context));
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Expected during shutdown
|
||||
break;
|
||||
}
|
||||
catch (HttpListenerException ex) when (ex.ErrorCode == 995)
|
||||
{
|
||||
// ERROR_OPERATION_ABORTED — expected during shutdown
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error accepting HTTP request");
|
||||
await Task.Delay(1000, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRequestAsync(HttpListenerContext context)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (context.Request.HttpMethod != "GET")
|
||||
{
|
||||
context.Response.StatusCode = 405;
|
||||
await WriteResponseAsync(context.Response, "Method Not Allowed", "text/plain");
|
||||
return;
|
||||
}
|
||||
|
||||
var path = context.Request.Url?.AbsolutePath?.ToLowerInvariant() ?? "/";
|
||||
|
||||
switch (path)
|
||||
{
|
||||
case "/":
|
||||
await HandleStatusPageAsync(context.Response);
|
||||
break;
|
||||
case "/api/status":
|
||||
await HandleStatusApiAsync(context.Response);
|
||||
break;
|
||||
case "/api/health":
|
||||
await HandleHealthApiAsync(context.Response);
|
||||
break;
|
||||
default:
|
||||
context.Response.StatusCode = 404;
|
||||
await WriteResponseAsync(context.Response, "Not Found", "text/plain");
|
||||
break;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, "Error handling HTTP request");
|
||||
try
|
||||
{
|
||||
context.Response.StatusCode = 500;
|
||||
await WriteResponseAsync(context.Response, "Internal Server Error", "text/plain");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore errors writing error response
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleStatusPageAsync(HttpListenerResponse response)
|
||||
{
|
||||
var html = await _statusReportService.GenerateHtmlReportAsync();
|
||||
await WriteResponseAsync(response, html, "text/html; charset=utf-8");
|
||||
}
|
||||
|
||||
private async Task HandleStatusApiAsync(HttpListenerResponse response)
|
||||
{
|
||||
var json = await _statusReportService.GenerateJsonReportAsync();
|
||||
await WriteResponseAsync(response, json, "application/json; charset=utf-8");
|
||||
}
|
||||
|
||||
private async Task HandleHealthApiAsync(HttpListenerResponse response)
|
||||
{
|
||||
var isHealthy = await _statusReportService.IsHealthyAsync();
|
||||
if (isHealthy)
|
||||
{
|
||||
response.StatusCode = 200;
|
||||
await WriteResponseAsync(response, "OK", "text/plain");
|
||||
}
|
||||
else
|
||||
{
|
||||
response.StatusCode = 503;
|
||||
await WriteResponseAsync(response, "UNHEALTHY", "text/plain");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
var buffer = Encoding.UTF8.GetBytes(content);
|
||||
response.ContentLength64 = buffer.Length;
|
||||
await response.OutputStream.WriteAsync(buffer, 0, buffer.Length);
|
||||
response.OutputStream.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
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.Domain;
|
||||
|
||||
namespace ZB.MOM.WW.LmxProxy.Host.Subscriptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Manages per-client subscription channels with shared MxAccess subscriptions.
|
||||
/// Ref-counted tag subscriptions: first client creates, last client disposes.
|
||||
/// </summary>
|
||||
public sealed class SubscriptionManager : IDisposable
|
||||
{
|
||||
private static readonly ILogger Log = Serilog.Log.ForContext<SubscriptionManager>();
|
||||
|
||||
private readonly IScadaClient _scadaClient;
|
||||
private readonly int _channelCapacity;
|
||||
private readonly BoundedChannelFullMode _channelFullMode;
|
||||
|
||||
// Subscription ID -> ClientSubscription
|
||||
private readonly ConcurrentDictionary<string, ClientSubscription> _clientSubscriptions
|
||||
= new ConcurrentDictionary<string, ClientSubscription>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Tag address -> TagSubscription (shared, ref-counted)
|
||||
private readonly ConcurrentDictionary<string, TagSubscription> _tagSubscriptions
|
||||
= new ConcurrentDictionary<string, TagSubscription>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Session ID -> set of subscription IDs owned by that session
|
||||
private readonly ConcurrentDictionary<string, HashSet<string>> _sessionSubscriptions
|
||||
= new ConcurrentDictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private readonly ReaderWriterLockSlim _rwLock = new ReaderWriterLockSlim();
|
||||
|
||||
public SubscriptionManager(IScadaClient scadaClient, int channelCapacity = 1000,
|
||||
BoundedChannelFullMode channelFullMode = BoundedChannelFullMode.DropOldest)
|
||||
{
|
||||
_scadaClient = scadaClient;
|
||||
_channelCapacity = channelCapacity;
|
||||
_channelFullMode = channelFullMode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a subscription for a session. Returns a ChannelReader and unique
|
||||
/// subscription ID. Multiple subscriptions per session are supported.
|
||||
/// Awaits COM subscription creation so the initial OnDataChange callback
|
||||
/// is not missed.
|
||||
/// </summary>
|
||||
public async Task<(ChannelReader<(string address, Vtq vtq)> Reader, string SubscriptionId)> SubscribeAsync(
|
||||
string sessionId, IEnumerable<string> addresses, CancellationToken ct)
|
||||
{
|
||||
var subscriptionId = Guid.NewGuid().ToString("N");
|
||||
var channel = Channel.CreateBounded<(string address, Vtq vtq)>(
|
||||
new BoundedChannelOptions(_channelCapacity)
|
||||
{
|
||||
FullMode = _channelFullMode,
|
||||
SingleReader = true,
|
||||
SingleWriter = false
|
||||
});
|
||||
|
||||
var addressSet = new HashSet<string>(addresses, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var clientSub = new ClientSubscription(subscriptionId, sessionId, channel, addressSet);
|
||||
_clientSubscriptions[subscriptionId] = clientSub;
|
||||
|
||||
// Track which session owns this subscription
|
||||
_sessionSubscriptions.AddOrUpdate(
|
||||
sessionId,
|
||||
_ => new HashSet<string>(StringComparer.OrdinalIgnoreCase) { subscriptionId },
|
||||
(_, set) => { lock (set) { set.Add(subscriptionId); } return set; });
|
||||
|
||||
var newTags = new List<string>();
|
||||
|
||||
_rwLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var address in addressSet)
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out var tagSub))
|
||||
{
|
||||
tagSub.ClientIds.Add(subscriptionId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_tagSubscriptions[address] = new TagSubscription(address,
|
||||
new HashSet<string>(StringComparer.OrdinalIgnoreCase) { subscriptionId });
|
||||
newTags.Add(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rwLock.ExitWriteLock();
|
||||
}
|
||||
|
||||
// Create MxAccess COM subscriptions — awaited so the initial
|
||||
// OnDataChange (first value delivery after AdviseSupervisory)
|
||||
// is not lost. The channel and routing are already set up above,
|
||||
// so any callback that fires during this call will be delivered.
|
||||
if (newTags.Count > 0)
|
||||
{
|
||||
await CreateMxAccessSubscriptionsAsync(newTags);
|
||||
}
|
||||
|
||||
// Register cancellation cleanup for this subscription only
|
||||
ct.Register(() => UnsubscribeSubscription(subscriptionId));
|
||||
|
||||
Log.Information("Session {SessionId} subscription {SubscriptionId} subscribed to {Count} tags ({NewCount} new MxAccess subscriptions)",
|
||||
sessionId, subscriptionId, addressSet.Count, newTags.Count);
|
||||
return (channel.Reader, subscriptionId);
|
||||
}
|
||||
|
||||
private async Task CreateMxAccessSubscriptionsAsync(List<string> addresses)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _scadaClient.SubscribeAsync(
|
||||
addresses,
|
||||
(address, vtq) => OnTagValueChanged(address, vtq));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Error(ex, "Failed to create MxAccess subscriptions for {Count} tags", addresses.Count);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called from MxAccessClient's OnDataChange handler.
|
||||
/// Fans out the update to all subscribed clients.
|
||||
/// </summary>
|
||||
public void OnTagValueChanged(string address, Vtq vtq)
|
||||
{
|
||||
_rwLock.EnterReadLock();
|
||||
HashSet<string>? clientIds = null;
|
||||
try
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out var tagSub))
|
||||
{
|
||||
clientIds = new HashSet<string>(tagSub.ClientIds);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rwLock.ExitReadLock();
|
||||
}
|
||||
|
||||
if (clientIds == null || clientIds.Count == 0) return;
|
||||
|
||||
foreach (var clientId in clientIds)
|
||||
{
|
||||
if (_clientSubscriptions.TryGetValue(clientId, out var clientSub))
|
||||
{
|
||||
if (!clientSub.Channel.Writer.TryWrite((address, vtq)))
|
||||
{
|
||||
clientSub.IncrementDropped();
|
||||
Log.Debug("Dropped message for client {ClientId} on tag {Address} (channel full)",
|
||||
clientId, address);
|
||||
}
|
||||
else
|
||||
{
|
||||
clientSub.IncrementDelivered();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes a single subscription and cleans up its tag refs.
|
||||
/// Called when an individual Subscribe stream ends.
|
||||
/// </summary>
|
||||
public void UnsubscribeSubscription(string subscriptionId)
|
||||
{
|
||||
if (!_clientSubscriptions.TryRemove(subscriptionId, out var clientSub))
|
||||
return;
|
||||
|
||||
// Remove from session tracking
|
||||
if (_sessionSubscriptions.TryGetValue(clientSub.SessionId, out var subIds))
|
||||
{
|
||||
lock (subIds)
|
||||
{
|
||||
subIds.Remove(subscriptionId);
|
||||
if (subIds.Count == 0)
|
||||
{
|
||||
_sessionSubscriptions.TryRemove(clientSub.SessionId, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var tagsToDispose = new List<string>();
|
||||
|
||||
_rwLock.EnterWriteLock();
|
||||
try
|
||||
{
|
||||
foreach (var address in clientSub.Addresses)
|
||||
{
|
||||
if (_tagSubscriptions.TryGetValue(address, out var tagSub))
|
||||
{
|
||||
tagSub.ClientIds.Remove(subscriptionId);
|
||||
|
||||
if (tagSub.ClientIds.Count == 0)
|
||||
{
|
||||
_tagSubscriptions.TryRemove(address, out _);
|
||||
tagsToDispose.Add(address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_rwLock.ExitWriteLock();
|
||||
}
|
||||
|
||||
if (tagsToDispose.Count > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
_scadaClient.UnsubscribeByAddressAsync(tagsToDispose).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Warning(ex, "Error unsubscribing {Count} tags from MxAccess", tagsToDispose.Count);
|
||||
}
|
||||
}
|
||||
|
||||
clientSub.Channel.Writer.TryComplete();
|
||||
|
||||
Log.Information("Subscription {SubscriptionId} removed ({Delivered} delivered, {Dropped} dropped)",
|
||||
subscriptionId, clientSub.DeliveredCount, clientSub.DroppedCount);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes ALL subscriptions for a session.
|
||||
/// Called on explicit Disconnect or session scavenging.
|
||||
/// </summary>
|
||||
public void UnsubscribeSession(string sessionId)
|
||||
{
|
||||
if (!_sessionSubscriptions.TryRemove(sessionId, out var subscriptionIds))
|
||||
return;
|
||||
|
||||
List<string> ids;
|
||||
lock (subscriptionIds)
|
||||
{
|
||||
ids = subscriptionIds.ToList();
|
||||
}
|
||||
|
||||
foreach (var subId in ids)
|
||||
{
|
||||
UnsubscribeSubscription(subId);
|
||||
}
|
||||
|
||||
Log.Information("All subscriptions for session {SessionId} removed ({Count} subscriptions)",
|
||||
sessionId, ids.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a bad-quality notification to all subscribed clients for all their tags.
|
||||
/// Called when MxAccess disconnects.
|
||||
/// </summary>
|
||||
public void NotifyDisconnection()
|
||||
{
|
||||
var badVtq = Vtq.New(null, Quality.Bad_NotConnected);
|
||||
|
||||
foreach (var kvp in _clientSubscriptions)
|
||||
{
|
||||
foreach (var address in kvp.Value.Addresses)
|
||||
{
|
||||
kvp.Value.Channel.Writer.TryWrite((address, badVtq));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Logs reconnection for observability. Data flow resumes automatically
|
||||
/// via MxAccessClient.RecreateStoredSubscriptionsAsync callbacks.
|
||||
/// </summary>
|
||||
public void NotifyReconnection()
|
||||
{
|
||||
Log.Information("MxAccess reconnected -- subscriptions recreated, " +
|
||||
"data flow will resume via OnDataChange callbacks " +
|
||||
"({ClientCount} clients, {TagCount} tags)",
|
||||
_clientSubscriptions.Count, _tagSubscriptions.Count);
|
||||
}
|
||||
|
||||
/// <summary>Returns subscription statistics.</summary>
|
||||
public SubscriptionStats GetStats()
|
||||
{
|
||||
long totalDelivered = 0;
|
||||
long totalDropped = 0;
|
||||
foreach (var kvp in _clientSubscriptions)
|
||||
{
|
||||
totalDelivered += kvp.Value.DeliveredCount;
|
||||
totalDropped += kvp.Value.DroppedCount;
|
||||
}
|
||||
|
||||
return new SubscriptionStats(
|
||||
_sessionSubscriptions.Count,
|
||||
_tagSubscriptions.Count,
|
||||
_clientSubscriptions.Values.Sum(c => c.Addresses.Count),
|
||||
totalDelivered,
|
||||
totalDropped);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
foreach (var kvp in _clientSubscriptions)
|
||||
{
|
||||
kvp.Value.Channel.Writer.TryComplete();
|
||||
}
|
||||
_clientSubscriptions.Clear();
|
||||
_sessionSubscriptions.Clear();
|
||||
_tagSubscriptions.Clear();
|
||||
_rwLock.Dispose();
|
||||
}
|
||||
|
||||
// ── Nested types ─────────────────────────────────────────
|
||||
|
||||
private class ClientSubscription
|
||||
{
|
||||
public ClientSubscription(string subscriptionId, string sessionId,
|
||||
Channel<(string address, Vtq vtq)> channel,
|
||||
HashSet<string> addresses)
|
||||
{
|
||||
SubscriptionId = subscriptionId;
|
||||
SessionId = sessionId;
|
||||
Channel = channel;
|
||||
Addresses = addresses;
|
||||
}
|
||||
|
||||
public string SubscriptionId { get; }
|
||||
public string SessionId { get; }
|
||||
public Channel<(string address, Vtq vtq)> Channel { get; }
|
||||
public HashSet<string> Addresses { get; }
|
||||
|
||||
// Use backing fields for Interlocked
|
||||
private long _delivered;
|
||||
private long _dropped;
|
||||
|
||||
public long DeliveredCount => Interlocked.Read(ref _delivered);
|
||||
public long DroppedCount => Interlocked.Read(ref _dropped);
|
||||
|
||||
public void IncrementDelivered() => Interlocked.Increment(ref _delivered);
|
||||
public void IncrementDropped() => Interlocked.Increment(ref _dropped);
|
||||
}
|
||||
|
||||
private class TagSubscription
|
||||
{
|
||||
public TagSubscription(string address, HashSet<string> clientIds)
|
||||
{
|
||||
Address = address;
|
||||
ClientIds = clientIds;
|
||||
}
|
||||
|
||||
public string Address { get; }
|
||||
public HashSet<string> ClientIds { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<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>
|
||||
<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.68.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Google.Protobuf" Version="3.29.3" />
|
||||
<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="Serilog.Enrichers.Environment" Version="2.2.0" />
|
||||
<PackageReference Include="Serilog.Enrichers.Thread" Version="3.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" />
|
||||
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
|
||||
</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>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,86 @@
|
||||
{
|
||||
"GrpcPort": 50051,
|
||||
"ApiKeyConfigFile": "apikeys.json",
|
||||
|
||||
"Connection": {
|
||||
"MonitorIntervalSeconds": 5,
|
||||
"ConnectionTimeoutSeconds": 30,
|
||||
"ReadTimeoutSeconds": 5,
|
||||
"WriteTimeoutSeconds": 5,
|
||||
"MaxConcurrentOperations": 10,
|
||||
"AutoReconnect": true,
|
||||
"NodeName": null,
|
||||
"GalaxyName": null
|
||||
},
|
||||
|
||||
"Subscription": {
|
||||
"ChannelCapacity": 1000,
|
||||
"ChannelFullMode": "DropOldest"
|
||||
},
|
||||
|
||||
"Tls": {
|
||||
"Enabled": false,
|
||||
"ServerCertificatePath": "certs/server.crt",
|
||||
"ServerKeyPath": "certs/server.key",
|
||||
"ClientCaCertificatePath": "certs/ca.crt",
|
||||
"RequireClientCertificate": false,
|
||||
"CheckCertificateRevocation": false
|
||||
},
|
||||
|
||||
"WebServer": {
|
||||
"Enabled": true,
|
||||
"Port": 8080
|
||||
},
|
||||
|
||||
"HealthCheck": {
|
||||
"TestTagAddress": "DevPlatform.Scheduler.ScanTime",
|
||||
"ProbeStaleThresholdMs": 5000
|
||||
},
|
||||
|
||||
"ServiceRecovery": {
|
||||
"FirstFailureDelayMinutes": 1,
|
||||
"SecondFailureDelayMinutes": 5,
|
||||
"SubsequentFailureDelayMinutes": 10,
|
||||
"ResetPeriodDays": 1
|
||||
},
|
||||
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File",
|
||||
"Serilog.Enrichers.Environment",
|
||||
"Serilog.Enrichers.Thread"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning",
|
||||
"Grpc": "Information",
|
||||
"ZB.MOM.WW.LmxProxy.Host.MxAccess.StaComThread": "Debug"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"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}] [{MachineName}/{ThreadId}] {Message:lj}{NewLine}{Exception}"
|
||||
}
|
||||
}
|
||||
],
|
||||
"Enrich": [
|
||||
"FromLogContext",
|
||||
"WithMachineName",
|
||||
"WithThreadId"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"GrpcPort": 50100,
|
||||
"WebServer": {
|
||||
"Port": 8081
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"GrpcPort": 50101,
|
||||
"WebServer": {
|
||||
"Port": 8082
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user