feat: add standalone LmxProxy solution, windev VM documentation

Split LmxProxy Host and Client into a self-contained solution under lmxproxy/,
ported from the ScadaBridge monorepo with updated namespaces (ZB.MOM.WW.LmxProxy.*).
Client project (.NET 10) inlines Core/DataEngine dependencies and builds clean.
Host project (.NET Fx 4.8) retains ArchestrA.MXAccess for Windows deployment.
Added windev.md documenting the WW_DEV_VM development environment setup.
This commit is contained in:
Joseph Doherty
2026-03-21 20:50:05 -04:00
parent 512153646a
commit 2810306415
64 changed files with 11276 additions and 0 deletions

View File

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