deprecate(lmxproxy): move all LmxProxy code, tests, and docs to deprecated/

LmxProxy is no longer needed. Moved the entire lmxproxy/ workspace, DCL
adapter files, and related docs to deprecated/. Removed LmxProxy registration
from DataConnectionFactory, project reference from DCL, protocol option from
UI, and cleaned up all requirement docs.
This commit is contained in:
Joseph Doherty
2026-04-08 15:56:23 -04:00
parent 8423915ba1
commit 9dccf8e72f
220 changed files with 25 additions and 132 deletions

View File

@@ -0,0 +1,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);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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&lt;T&gt; 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
}
}

View 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();
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
{
"GrpcPort": 50100,
"WebServer": {
"Port": 8081
}
}

View File

@@ -0,0 +1,6 @@
{
"GrpcPort": 50101,
"WebServer": {
"Port": 8082
}
}