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.
805 lines
30 KiB
C#
805 lines
30 KiB
C#
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
|
|
}
|
|
}
|