rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Worker environment that reads from system environment variables.
|
||||
/// </summary>
|
||||
public sealed class EnvironmentVariableWorkerEnvironment : IWorkerEnvironment
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public string? GetEnvironmentVariable(string name)
|
||||
{
|
||||
return Environment.GetEnvironmentVariable(name);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Abstracts access to environment variables for the worker.
|
||||
/// </summary>
|
||||
public interface IWorkerEnvironment
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets an environment variable by name.
|
||||
/// </summary>
|
||||
/// <param name="name">Name of the environment variable.</param>
|
||||
/// <returns>The value of the environment variable, or null if not found.</returns>
|
||||
string? GetEnvironmentVariable(string name);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
public interface IWorkerLogger
|
||||
{
|
||||
/// <summary>
|
||||
/// Logs an informational event with fields.
|
||||
/// </summary>
|
||||
/// <param name="eventName">Event name.</param>
|
||||
/// <param name="fields">Event fields.</param>
|
||||
void Information(string eventName, IReadOnlyDictionary<string, object?> fields);
|
||||
|
||||
/// <summary>
|
||||
/// Logs an error event with fields.
|
||||
/// </summary>
|
||||
/// <param name="eventName">Event name.</param>
|
||||
/// <param name="fields">Event fields.</param>
|
||||
void Error(string eventName, IReadOnlyDictionary<string, object?> fields);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
public sealed class WorkerBootstrapResult
|
||||
{
|
||||
private WorkerBootstrapResult(
|
||||
WorkerExitCode exitCode,
|
||||
WorkerOptions? options,
|
||||
IReadOnlyList<string> errors)
|
||||
{
|
||||
ExitCode = exitCode;
|
||||
Options = options;
|
||||
Errors = errors;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the worker process exit code.
|
||||
/// </summary>
|
||||
public WorkerExitCode ExitCode { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the bootstrap options if bootstrap succeeded.
|
||||
/// </summary>
|
||||
public WorkerOptions? Options { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the list of bootstrap errors if any.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Errors { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether bootstrap succeeded.
|
||||
/// </summary>
|
||||
public bool Succeeded => ExitCode == WorkerExitCode.Success;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a successful bootstrap result with the given options.
|
||||
/// </summary>
|
||||
/// <param name="options">Bootstrap options.</param>
|
||||
/// <returns>Successful bootstrap result.</returns>
|
||||
public static WorkerBootstrapResult Success(WorkerOptions options)
|
||||
{
|
||||
return new WorkerBootstrapResult(WorkerExitCode.Success, options, []);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed bootstrap result with the given exit code and errors.
|
||||
/// </summary>
|
||||
/// <param name="exitCode">Worker exit code.</param>
|
||||
/// <param name="errors">Bootstrap errors.</param>
|
||||
/// <returns>Failed bootstrap result.</returns>
|
||||
public static WorkerBootstrapResult Failure(WorkerExitCode exitCode, IEnumerable<string> errors)
|
||||
{
|
||||
return new WorkerBootstrapResult(exitCode, null, errors.ToArray());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
public sealed class WorkerConsoleLogger : IWorkerLogger
|
||||
{
|
||||
private readonly TextWriter _writer;
|
||||
|
||||
/// <summary>Initializes a new worker console logger.</summary>
|
||||
/// <param name="writer">Text writer destination for log output.</param>
|
||||
public WorkerConsoleLogger(TextWriter writer)
|
||||
{
|
||||
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
|
||||
}
|
||||
|
||||
/// <summary>Writes an informational log entry.</summary>
|
||||
/// <param name="eventName">Name of the event being logged.</param>
|
||||
/// <param name="fields">Event fields and values to log.</param>
|
||||
public void Information(string eventName, IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Write("Information", eventName, fields);
|
||||
}
|
||||
|
||||
/// <summary>Writes an error log entry.</summary>
|
||||
/// <param name="eventName">Name of the event being logged.</param>
|
||||
/// <param name="fields">Event fields and values to log.</param>
|
||||
public void Error(string eventName, IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Write("Error", eventName, fields);
|
||||
}
|
||||
|
||||
private void Write(
|
||||
string level,
|
||||
string eventName,
|
||||
IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Dictionary<string, object?> redactedFields = WorkerLogRedactor.RedactFields(fields);
|
||||
string fieldText = string.Join(
|
||||
" ",
|
||||
redactedFields.Select(field => $"{field.Key}={FormatValue(field.Value)}"));
|
||||
|
||||
_writer.WriteLine($"level={level} event={eventName} {fieldText}".TrimEnd());
|
||||
}
|
||||
|
||||
private static string FormatValue(object? value)
|
||||
{
|
||||
return value?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
public enum WorkerExitCode
|
||||
{
|
||||
Success = 0,
|
||||
UnexpectedFailure = 1,
|
||||
InvalidArguments = 2,
|
||||
InvalidProtocolVersion = 3,
|
||||
MissingNonce = 4,
|
||||
PipeConnectionFailed = 5,
|
||||
ProtocolViolation = 6,
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Redacts sensitive fields from worker log messages.
|
||||
/// </summary>
|
||||
public static class WorkerLogRedactor
|
||||
{
|
||||
/// <summary>
|
||||
/// Replacement text for redacted values.
|
||||
/// </summary>
|
||||
public const string RedactedValue = "[redacted]";
|
||||
|
||||
private static readonly string[] SensitiveFieldNameParts =
|
||||
[
|
||||
"nonce",
|
||||
"secret",
|
||||
"password",
|
||||
"token",
|
||||
"credential",
|
||||
"apikey",
|
||||
"api_key",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Redacts sensitive field values from a log field dictionary.
|
||||
/// </summary>
|
||||
/// <param name="fields">Dictionary of field names and values.</param>
|
||||
public static Dictionary<string, object?> RedactFields(IReadOnlyDictionary<string, object?> fields)
|
||||
{
|
||||
Dictionary<string, object?> redactedFields = [];
|
||||
|
||||
foreach (KeyValuePair<string, object?> field in fields)
|
||||
{
|
||||
redactedFields[field.Key] = RedactValue(field.Key, field.Value);
|
||||
}
|
||||
|
||||
return redactedFields;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts a single value if its field name contains sensitive keywords.
|
||||
/// </summary>
|
||||
/// <param name="fieldName">Name of the field to check.</param>
|
||||
/// <param name="value">Value to redact if sensitive.</param>
|
||||
public static object? RedactValue(string fieldName, object? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (string sensitiveFieldNamePart in SensitiveFieldNameParts)
|
||||
{
|
||||
if (fieldName.IndexOf(sensitiveFieldNamePart, StringComparison.OrdinalIgnoreCase) >= 0)
|
||||
{
|
||||
return RedactedValue;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
/// <summary>Worker bootstrap options passed via environment variables and named pipes.</summary>
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
/// <summary>Environment variable name for the worker nonce.</summary>
|
||||
public const string NonceEnvironmentVariableName = "MXGATEWAY_WORKER_NONCE";
|
||||
|
||||
/// <summary>Initializes worker options from a bootstrap handshake.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="pipeName">Named pipe name for gateway communication.</param>
|
||||
/// <param name="protocolVersion">Protocol version agreed with the gateway.</param>
|
||||
/// <param name="nonce">Authentication nonce for the handshake.</param>
|
||||
public WorkerOptions(
|
||||
string sessionId,
|
||||
string pipeName,
|
||||
uint protocolVersion,
|
||||
string nonce)
|
||||
{
|
||||
SessionId = sessionId;
|
||||
PipeName = pipeName;
|
||||
ProtocolVersion = protocolVersion;
|
||||
Nonce = nonce;
|
||||
}
|
||||
|
||||
/// <summary>Unique identifier for the gateway session this worker serves.</summary>
|
||||
public string SessionId { get; }
|
||||
|
||||
/// <summary>Named pipe name for communicating with the gateway.</summary>
|
||||
public string PipeName { get; }
|
||||
|
||||
/// <summary>Worker protocol version negotiated with the gateway.</summary>
|
||||
public uint ProtocolVersion { get; }
|
||||
|
||||
/// <summary>Nonce used to authenticate the handshake with the gateway.</summary>
|
||||
public string Nonce { get; }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
/// <summary>
|
||||
/// Parses worker command-line arguments and environment variables.
|
||||
/// </summary>
|
||||
public sealed class WorkerOptionsParser
|
||||
{
|
||||
private const string SessionIdOptionName = "--session-id";
|
||||
private const string PipeNameOptionName = "--pipe-name";
|
||||
private const string ProtocolVersionOptionName = "--protocol-version";
|
||||
|
||||
private readonly IWorkerEnvironment _environment;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the parser with a worker environment.
|
||||
/// </summary>
|
||||
/// <param name="environment">Worker environment for reading configuration.</param>
|
||||
public WorkerOptionsParser(IWorkerEnvironment environment)
|
||||
{
|
||||
_environment = environment ?? throw new ArgumentNullException(nameof(environment));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses command-line arguments and returns bootstrap configuration or errors.
|
||||
/// </summary>
|
||||
/// <param name="args">Command-line arguments to parse.</param>
|
||||
/// <returns>Bootstrap result containing configuration or error messages.</returns>
|
||||
public WorkerBootstrapResult Parse(string[] args)
|
||||
{
|
||||
if (args is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(args));
|
||||
}
|
||||
|
||||
Dictionary<string, string> values = new(StringComparer.OrdinalIgnoreCase);
|
||||
List<string> errors = [];
|
||||
|
||||
for (int index = 0; index < args.Length; index++)
|
||||
{
|
||||
string arg = args[index];
|
||||
if (!IsKnownOption(arg))
|
||||
{
|
||||
errors.Add($"Unknown option '{arg}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (index + 1 >= args.Length || args[index + 1].StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
errors.Add($"Option '{arg}' requires a value.");
|
||||
continue;
|
||||
}
|
||||
|
||||
values[arg] = args[index + 1];
|
||||
index++;
|
||||
}
|
||||
|
||||
string? sessionId = ReadRequired(values, SessionIdOptionName, errors);
|
||||
string? pipeName = ReadRequired(values, PipeNameOptionName, errors);
|
||||
string? protocolVersionText = ReadRequired(values, ProtocolVersionOptionName, errors);
|
||||
|
||||
if (errors.Count > 0)
|
||||
{
|
||||
return WorkerBootstrapResult.Failure(WorkerExitCode.InvalidArguments, errors);
|
||||
}
|
||||
|
||||
if (!uint.TryParse(protocolVersionText, out uint protocolVersion)
|
||||
|| protocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||
{
|
||||
return WorkerBootstrapResult.Failure(
|
||||
WorkerExitCode.InvalidProtocolVersion,
|
||||
[$"Unsupported protocol version '{protocolVersionText}'."]);
|
||||
}
|
||||
|
||||
string? nonce = _environment.GetEnvironmentVariable(WorkerOptions.NonceEnvironmentVariableName);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
return WorkerBootstrapResult.Failure(
|
||||
WorkerExitCode.MissingNonce,
|
||||
["Required worker nonce environment variable is missing."]);
|
||||
}
|
||||
|
||||
return WorkerBootstrapResult.Success(new WorkerOptions(
|
||||
sessionId!,
|
||||
pipeName!,
|
||||
protocolVersion,
|
||||
nonce!));
|
||||
}
|
||||
|
||||
private static string? ReadRequired(
|
||||
IReadOnlyDictionary<string, string> values,
|
||||
string optionName,
|
||||
List<string> errors)
|
||||
{
|
||||
if (!values.TryGetValue(optionName, out string value)
|
||||
|| string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
errors.Add($"Required option '{optionName}' is missing.");
|
||||
return null;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
private static bool IsKnownOption(string optionName)
|
||||
{
|
||||
return optionName is SessionIdOptionName or PipeNameOptionName or ProtocolVersionOptionName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
/// <summary>Result of converting an HResult to a protocol status and diagnostic message.</summary>
|
||||
public sealed class HResultConversion
|
||||
{
|
||||
/// <summary>Initializes the conversion result.</summary>
|
||||
/// <param name="hresult">The original HResult value.</param>
|
||||
/// <param name="protocolStatus">The converted protocol status.</param>
|
||||
/// <param name="diagnosticMessage">Diagnostic message describing the HResult.</param>
|
||||
public HResultConversion(
|
||||
int hresult,
|
||||
ProtocolStatus protocolStatus,
|
||||
string diagnosticMessage)
|
||||
{
|
||||
HResult = hresult;
|
||||
ProtocolStatus = protocolStatus;
|
||||
DiagnosticMessage = diagnosticMessage;
|
||||
}
|
||||
|
||||
/// <summary>The original HResult value.</summary>
|
||||
public int HResult { get; }
|
||||
|
||||
/// <summary>The converted protocol status.</summary>
|
||||
public ProtocolStatus ProtocolStatus { get; }
|
||||
|
||||
/// <summary>Diagnostic message describing the HResult.</summary>
|
||||
public string DiagnosticMessage { get; }
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
public sealed class HResultConverter
|
||||
{
|
||||
/// <summary>Converts an exception to an HResult conversion with protocol status and diagnostic message.</summary>
|
||||
/// <param name="exception">Exception to convert.</param>
|
||||
/// <returns>Conversion result with HResult, protocol status, and diagnostics.</returns>
|
||||
public HResultConversion Convert(Exception exception)
|
||||
{
|
||||
if (exception is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(exception));
|
||||
}
|
||||
|
||||
int hresult = exception is COMException comException
|
||||
? comException.ErrorCode
|
||||
: exception.HResult;
|
||||
|
||||
return new HResultConversion(
|
||||
hresult,
|
||||
CreateProtocolStatus(hresult, exception),
|
||||
CreateSafeDiagnosticMessage(exception));
|
||||
}
|
||||
|
||||
/// <summary>Creates a protocol status from an HResult code and optional exception.</summary>
|
||||
/// <param name="hresult">HResult error code.</param>
|
||||
/// <param name="exception">Exception providing additional context.</param>
|
||||
/// <returns>Protocol status with mapped code and message.</returns>
|
||||
public ProtocolStatus CreateProtocolStatus(
|
||||
int hresult,
|
||||
Exception? exception = null)
|
||||
{
|
||||
return new ProtocolStatus
|
||||
{
|
||||
Code = hresult == 0 ? ProtocolStatusCode.Ok : ProtocolStatusCode.MxaccessFailure,
|
||||
Message = exception is null
|
||||
? FormatHResult(hresult)
|
||||
: $"{exception.GetType().Name}: {FormatHResult(hresult)}",
|
||||
};
|
||||
}
|
||||
|
||||
private static string CreateSafeDiagnosticMessage(Exception exception)
|
||||
{
|
||||
return $"{exception.GetType().FullName}: {FormatHResult(exception.HResult)}";
|
||||
}
|
||||
|
||||
private static string FormatHResult(int hresult)
|
||||
{
|
||||
return $"HRESULT 0x{unchecked((uint)hresult):X8}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
public sealed class MxStatusConversionException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="MxStatusConversionException"/> class.</summary>
|
||||
/// <param name="message">The exception message.</param>
|
||||
public MxStatusConversionException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
internal static class MxStatusDetailText
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<int, string> KnownDetails = new Dictionary<int, string>
|
||||
{
|
||||
[16] = "Request timed out",
|
||||
[17] = "Platform communication error",
|
||||
[18] = "Invalid platform ID",
|
||||
[19] = "Invalid engine ID",
|
||||
[20] = "Engine communication error",
|
||||
[21] = "Invalid reference",
|
||||
[22] = "No Galaxy Repository",
|
||||
[23] = "Invalid object ID",
|
||||
[24] = "Object signature mismatch",
|
||||
[25] = "Invalid primitive ID",
|
||||
[26] = "Invalid attribute ID",
|
||||
[27] = "Invalid property ID",
|
||||
[28] = "Index out of range",
|
||||
[29] = "Data out of range",
|
||||
[30] = "Incorrect data type",
|
||||
[31] = "Attribute not readable",
|
||||
[32] = "Attribute not writeable",
|
||||
[33] = "Write access denied",
|
||||
[34] = "Unknown error",
|
||||
[36] = "Wrong data type",
|
||||
[37] = "Wrong number of dimensions",
|
||||
[38] = "Invalid index",
|
||||
[39] = "Index out of order",
|
||||
[40] = "Dimension does not exist",
|
||||
[41] = "Conversion not supported",
|
||||
[42] = "Unable to convert string",
|
||||
[43] = "Overflow",
|
||||
[44] = "Attribute signature mismatch",
|
||||
[47] = "Nmx version mismatch",
|
||||
[48] = "Nmx command not valid",
|
||||
[49] = "Lmx version mismatch",
|
||||
[50] = "Lmx command not valid",
|
||||
[56] = "Secured Write",
|
||||
[57] = "Verified Write",
|
||||
[60] = "User did not have the necessary permissions to write",
|
||||
[61] = "Verifier did not have the necessary permissions to verify",
|
||||
[541] = "Conversion to intended data type is not supported",
|
||||
[542] = "Unable to convert the input string to intended data type",
|
||||
[8017] = "Object must be offscan to modify attributes that have an MxSecurityConfigure security classification",
|
||||
};
|
||||
|
||||
/// <summary>Looks up the text description for an MXAccess status detail code.</summary>
|
||||
/// <param name="detail">Status detail code.</param>
|
||||
/// <returns>Description text if found; otherwise empty string.</returns>
|
||||
public static string Lookup(int detail)
|
||||
{
|
||||
return KnownDetails.TryGetValue(detail, out string text) ? text : string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
/// <summary>Converts MXAccess MXSTATUS_PROXY COM objects to protobuf MxStatusProxy messages.</summary>
|
||||
public sealed class MxStatusProxyConverter
|
||||
{
|
||||
/// <summary>Converts a single status object to a protobuf message, reflecting all fields and diagnostics.</summary>
|
||||
/// <param name="status">COM status object to convert.</param>
|
||||
public MxStatusProxy Convert(object status)
|
||||
{
|
||||
if (status is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(status));
|
||||
}
|
||||
|
||||
Type statusType = status.GetType();
|
||||
int success = ReadInt32Field(status, statusType, "success");
|
||||
int rawCategory = ReadInt32Field(status, statusType, "category");
|
||||
int rawDetectedBy = ReadInt32Field(status, statusType, "detectedBy");
|
||||
int detail = ReadInt32Field(status, statusType, "detail");
|
||||
|
||||
return new MxStatusProxy
|
||||
{
|
||||
Success = success,
|
||||
Category = MapCategory(rawCategory),
|
||||
DetectedBy = MapSource(rawDetectedBy),
|
||||
Detail = detail,
|
||||
RawCategory = rawCategory,
|
||||
RawDetectedBy = rawDetectedBy,
|
||||
DiagnosticText = MxStatusDetailText.Lookup(detail),
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Converts an array of status objects, handling nulls gracefully.</summary>
|
||||
/// <param name="statuses">Array of COM status objects; null returns empty list.</param>
|
||||
public IReadOnlyList<MxStatusProxy> ConvertMany(Array? statuses)
|
||||
{
|
||||
if (statuses is null)
|
||||
{
|
||||
return Array.Empty<MxStatusProxy>();
|
||||
}
|
||||
|
||||
List<MxStatusProxy> converted = new(statuses.Length);
|
||||
foreach (object? status in statuses)
|
||||
{
|
||||
if (status is null)
|
||||
{
|
||||
converted.Add(new MxStatusProxy
|
||||
{
|
||||
Category = MxStatusCategory.Unknown,
|
||||
DetectedBy = MxStatusSource.Unknown,
|
||||
DiagnosticText = "Null MXSTATUS_PROXY entry.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
converted.Add(Convert(status));
|
||||
}
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
/// <summary>Preserves completion-only status bytes as a diagnostic hex string since they cannot be unpacked.</summary>
|
||||
/// <param name="statusBytes">Status bytes to encode as hex string.</param>
|
||||
public string PreserveCompletionOnlyStatusBytes(byte[] statusBytes)
|
||||
{
|
||||
if (statusBytes is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(statusBytes));
|
||||
}
|
||||
|
||||
return $"completion_only_status_hex={BitConverter.ToString(statusBytes).Replace("-", string.Empty)}";
|
||||
}
|
||||
|
||||
private static int ReadInt32Field(
|
||||
object value,
|
||||
Type valueType,
|
||||
string fieldName)
|
||||
{
|
||||
FieldInfo? field = valueType.GetField(fieldName, BindingFlags.Instance | BindingFlags.Public);
|
||||
if (field is null)
|
||||
{
|
||||
throw new MxStatusConversionException(
|
||||
$"Status object type '{valueType.FullName}' does not expose required field '{fieldName}'.");
|
||||
}
|
||||
|
||||
object? fieldValue = field.GetValue(value);
|
||||
if (fieldValue is null)
|
||||
{
|
||||
throw new MxStatusConversionException(
|
||||
$"Status object field '{fieldName}' on type '{valueType.FullName}' is null.");
|
||||
}
|
||||
|
||||
return System.Convert.ToInt32(fieldValue, CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private static MxStatusCategory MapCategory(int rawCategory)
|
||||
{
|
||||
return rawCategory switch
|
||||
{
|
||||
-1 => MxStatusCategory.Unknown,
|
||||
0 => MxStatusCategory.Ok,
|
||||
1 => MxStatusCategory.Pending,
|
||||
2 => MxStatusCategory.Warning,
|
||||
3 => MxStatusCategory.CommunicationError,
|
||||
4 => MxStatusCategory.ConfigurationError,
|
||||
5 => MxStatusCategory.OperationalError,
|
||||
6 => MxStatusCategory.SecurityError,
|
||||
7 => MxStatusCategory.SoftwareError,
|
||||
8 => MxStatusCategory.OtherError,
|
||||
_ => MxStatusCategory.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private static MxStatusSource MapSource(int rawDetectedBy)
|
||||
{
|
||||
return rawDetectedBy switch
|
||||
{
|
||||
-1 => MxStatusSource.Unknown,
|
||||
0 => MxStatusSource.RequestingLmx,
|
||||
1 => MxStatusSource.RespondingLmx,
|
||||
2 => MxStatusSource.RequestingNmx,
|
||||
3 => MxStatusSource.RespondingNmx,
|
||||
4 => MxStatusSource.RequestingAutomationObject,
|
||||
5 => MxStatusSource.RespondingAutomationObject,
|
||||
_ => MxStatusSource.Unknown,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,604 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using Google.Protobuf;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
public sealed class VariantConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts an object value to an MxValue without a specified data type.
|
||||
/// </summary>
|
||||
/// <param name="value">Value to convert.</param>
|
||||
/// <returns>Converted MxValue.</returns>
|
||||
public MxValue Convert(object? value)
|
||||
{
|
||||
return Convert(value, MxDataType.Unspecified);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an object value to an MxValue with an expected data type.
|
||||
/// </summary>
|
||||
/// <param name="value">Value to convert.</param>
|
||||
/// <param name="expectedDataType">Expected MXAccess data type.</param>
|
||||
/// <returns>Converted MxValue.</returns>
|
||||
public MxValue Convert(
|
||||
object? value,
|
||||
MxDataType expectedDataType)
|
||||
{
|
||||
if (value is null || value is DBNull)
|
||||
{
|
||||
return CreateNullValue(value, expectedDataType);
|
||||
}
|
||||
|
||||
if (value is Array array)
|
||||
{
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Unspecified,
|
||||
VariantType = CreateArrayVariantType(array),
|
||||
ArrayValue = ConvertArray(array, expectedDataType),
|
||||
};
|
||||
}
|
||||
|
||||
return ConvertScalar(value, expectedDataType);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a .NET array to an MxArray.
|
||||
/// </summary>
|
||||
/// <param name="array">Array to convert.</param>
|
||||
/// <param name="expectedElementDataType">Expected data type for array elements.</param>
|
||||
/// <returns>Converted MxArray.</returns>
|
||||
public MxArray ConvertArray(
|
||||
Array array,
|
||||
MxDataType expectedElementDataType = MxDataType.Unspecified)
|
||||
{
|
||||
if (array is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(array));
|
||||
}
|
||||
|
||||
MxArray mxArray = new()
|
||||
{
|
||||
VariantType = CreateArrayVariantType(array),
|
||||
};
|
||||
|
||||
for (int dimension = 0; dimension < array.Rank; dimension++)
|
||||
{
|
||||
mxArray.Dimensions.Add((uint)array.GetLength(dimension));
|
||||
}
|
||||
|
||||
System.Type? elementType = array.GetType().GetElementType();
|
||||
MxDataType elementDataType = ResolveArrayElementDataType(elementType, expectedElementDataType);
|
||||
mxArray.ElementDataType = elementDataType;
|
||||
|
||||
switch (elementDataType)
|
||||
{
|
||||
case MxDataType.Boolean:
|
||||
mxArray.BoolValues = ConvertBoolArray(array);
|
||||
return mxArray;
|
||||
|
||||
case MxDataType.Integer:
|
||||
if (elementType == typeof(long) || elementType == typeof(ulong))
|
||||
{
|
||||
mxArray.Int64Values = ConvertInt64Array(array);
|
||||
}
|
||||
else
|
||||
{
|
||||
mxArray.Int32Values = ConvertInt32Array(array);
|
||||
}
|
||||
|
||||
return mxArray;
|
||||
|
||||
case MxDataType.Float:
|
||||
mxArray.FloatValues = ConvertFloatArray(array);
|
||||
return mxArray;
|
||||
|
||||
case MxDataType.Double:
|
||||
mxArray.DoubleValues = ConvertDoubleArray(array);
|
||||
return mxArray;
|
||||
|
||||
case MxDataType.String:
|
||||
mxArray.StringValues = ConvertStringArray(array);
|
||||
return mxArray;
|
||||
|
||||
case MxDataType.Time:
|
||||
mxArray.TimestampValues = ConvertTimestampArray(array);
|
||||
return mxArray;
|
||||
|
||||
default:
|
||||
mxArray.ElementDataType = MxDataType.Unknown;
|
||||
mxArray.RawElementDataType = (int)expectedElementDataType;
|
||||
mxArray.RawDiagnostic = CreateRawDiagnostic(array);
|
||||
mxArray.RawValues = ConvertRawArray(array);
|
||||
return mxArray;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts an <see cref="MxValue"/> into a CLR object suitable for an
|
||||
/// MXAccess COM write. The COM marshaler boxes the returned value into the
|
||||
/// matching VARIANT, so this is the inverse of <see cref="Convert(object?)"/>.
|
||||
/// </summary>
|
||||
/// <param name="value">Protobuf value to convert.</param>
|
||||
/// <returns>A COM-marshalable value, or <see langword="null"/> for an MXAccess null.</returns>
|
||||
public object? ConvertToComValue(MxValue value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(value));
|
||||
}
|
||||
|
||||
if (value.IsNull)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.KindCase switch
|
||||
{
|
||||
MxValue.KindOneofCase.BoolValue => value.BoolValue,
|
||||
MxValue.KindOneofCase.Int32Value => value.Int32Value,
|
||||
MxValue.KindOneofCase.Int64Value => value.Int64Value,
|
||||
MxValue.KindOneofCase.FloatValue => value.FloatValue,
|
||||
MxValue.KindOneofCase.DoubleValue => value.DoubleValue,
|
||||
MxValue.KindOneofCase.StringValue => value.StringValue,
|
||||
// The COM marshaler renders a DateTime as VT_DATE; MXAccess accepts
|
||||
// it as the timestamped-write time argument.
|
||||
MxValue.KindOneofCase.TimestampValue => value.TimestampValue.ToDateTime(),
|
||||
MxValue.KindOneofCase.ArrayValue => ConvertToComArray(value.ArrayValue),
|
||||
MxValue.KindOneofCase.RawValue => throw new ArgumentException(
|
||||
"MxValue raw payloads cannot be written to MXAccess.", nameof(value)),
|
||||
_ => throw new ArgumentException(
|
||||
"MxValue has no value kind set; nothing to write.", nameof(value)),
|
||||
};
|
||||
}
|
||||
|
||||
private static Array ConvertToComArray(MxArray array)
|
||||
{
|
||||
return array.ValuesCase switch
|
||||
{
|
||||
MxArray.ValuesOneofCase.BoolValues => array.BoolValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.Int32Values => array.Int32Values.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.Int64Values => array.Int64Values.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.FloatValues => array.FloatValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.DoubleValues => array.DoubleValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.StringValues => array.StringValues.Values.ToArray(),
|
||||
MxArray.ValuesOneofCase.TimestampValues =>
|
||||
array.TimestampValues.Values.Select(timestamp => timestamp.ToDateTime()).ToArray(),
|
||||
MxArray.ValuesOneofCase.RawValues => throw new ArgumentException(
|
||||
"MxArray raw payloads cannot be written to MXAccess.", nameof(array)),
|
||||
_ => throw new ArgumentException(
|
||||
"MxArray has no element values set; nothing to write.", nameof(array)),
|
||||
};
|
||||
}
|
||||
|
||||
private static MxValue ConvertScalar(
|
||||
object value,
|
||||
MxDataType expectedDataType)
|
||||
{
|
||||
System.Type valueType = value.GetType();
|
||||
string variantType = GetVariantTypeName(valueType);
|
||||
|
||||
switch (System.Type.GetTypeCode(valueType))
|
||||
{
|
||||
case TypeCode.Boolean:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Boolean,
|
||||
VariantType = variantType,
|
||||
BoolValue = (bool)value,
|
||||
};
|
||||
|
||||
case TypeCode.Byte:
|
||||
case TypeCode.SByte:
|
||||
case TypeCode.Int16:
|
||||
case TypeCode.UInt16:
|
||||
case TypeCode.Int32:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Integer,
|
||||
VariantType = variantType,
|
||||
Int32Value = System.Convert.ToInt32(value, CultureInfo.InvariantCulture),
|
||||
};
|
||||
|
||||
case TypeCode.UInt32:
|
||||
case TypeCode.Int64:
|
||||
return ConvertInt64Scalar(value, variantType, expectedDataType);
|
||||
|
||||
case TypeCode.UInt64:
|
||||
return ConvertUInt64Scalar((ulong)value, variantType, expectedDataType);
|
||||
|
||||
case TypeCode.Single:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
VariantType = variantType,
|
||||
FloatValue = (float)value,
|
||||
};
|
||||
|
||||
case TypeCode.Double:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Double,
|
||||
VariantType = variantType,
|
||||
DoubleValue = (double)value,
|
||||
};
|
||||
|
||||
case TypeCode.Decimal:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Double,
|
||||
VariantType = variantType,
|
||||
DoubleValue = System.Convert.ToDouble(value, CultureInfo.InvariantCulture),
|
||||
RawDiagnostic = "Decimal value projected to double.",
|
||||
};
|
||||
|
||||
case TypeCode.String:
|
||||
case TypeCode.Char:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.String,
|
||||
VariantType = variantType,
|
||||
StringValue = System.Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty,
|
||||
};
|
||||
|
||||
case TypeCode.DateTime:
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Time,
|
||||
VariantType = variantType,
|
||||
TimestampValue = ToTimestamp((DateTime)value),
|
||||
};
|
||||
|
||||
default:
|
||||
return CreateRawValue(value, expectedDataType);
|
||||
}
|
||||
}
|
||||
|
||||
private static MxValue ConvertInt64Scalar(
|
||||
object value,
|
||||
string variantType,
|
||||
MxDataType expectedDataType)
|
||||
{
|
||||
long longValue = System.Convert.ToInt64(value, CultureInfo.InvariantCulture);
|
||||
|
||||
// The MxDataType.Time projection treats the source as a Windows FILETIME
|
||||
// (a 64-bit 100-ns tick count since 1601). Only a genuine 64-bit source
|
||||
// (long) can carry a valid full FILETIME; a uint can only hold the low
|
||||
// 32 bits, which DateTime.FromFileTimeUtc would silently render as a
|
||||
// near-1601 timestamp. For uint sources fall through to the integer
|
||||
// projection rather than producing a bogus timestamp.
|
||||
if (expectedDataType == MxDataType.Time && value is long)
|
||||
{
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Time,
|
||||
VariantType = variantType,
|
||||
TimestampValue = Timestamp.FromDateTime(DateTime.FromFileTimeUtc(longValue)),
|
||||
};
|
||||
}
|
||||
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Integer,
|
||||
VariantType = variantType,
|
||||
Int64Value = longValue,
|
||||
};
|
||||
}
|
||||
|
||||
private static MxValue ConvertUInt64Scalar(
|
||||
ulong value,
|
||||
string variantType,
|
||||
MxDataType expectedDataType)
|
||||
{
|
||||
if (expectedDataType == MxDataType.Time && value <= long.MaxValue)
|
||||
{
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Time,
|
||||
VariantType = variantType,
|
||||
TimestampValue = Timestamp.FromDateTime(DateTime.FromFileTimeUtc((long)value)),
|
||||
};
|
||||
}
|
||||
|
||||
if (value <= long.MaxValue)
|
||||
{
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Integer,
|
||||
VariantType = variantType,
|
||||
Int64Value = (long)value,
|
||||
};
|
||||
}
|
||||
|
||||
return CreateRawValue(value, expectedDataType, "UInt64 value exceeds Int64 range.");
|
||||
}
|
||||
|
||||
private static MxValue CreateNullValue(
|
||||
object? value,
|
||||
MxDataType expectedDataType)
|
||||
{
|
||||
return new MxValue
|
||||
{
|
||||
DataType = expectedDataType == MxDataType.Unspecified ? MxDataType.NoData : expectedDataType,
|
||||
VariantType = value is DBNull ? "VT_NULL" : "VT_EMPTY",
|
||||
IsNull = true,
|
||||
};
|
||||
}
|
||||
|
||||
private static MxValue CreateRawValue(
|
||||
object value,
|
||||
MxDataType expectedDataType,
|
||||
string? diagnosticPrefix = null)
|
||||
{
|
||||
string diagnostic = CreateRawDiagnostic(value);
|
||||
if (!string.IsNullOrWhiteSpace(diagnosticPrefix))
|
||||
{
|
||||
diagnostic = $"{diagnosticPrefix} {diagnostic}";
|
||||
}
|
||||
|
||||
return new MxValue
|
||||
{
|
||||
DataType = MxDataType.Unknown,
|
||||
VariantType = GetVariantTypeName(value.GetType()),
|
||||
RawDataType = (int)expectedDataType,
|
||||
RawDiagnostic = diagnostic,
|
||||
RawValue = ByteString.CopyFromUtf8(System.Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty),
|
||||
};
|
||||
}
|
||||
|
||||
private static BoolArray ConvertBoolArray(Array array)
|
||||
{
|
||||
BoolArray values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
values.Values.Add(item is not null && System.Convert.ToBoolean(item, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Int32Array ConvertInt32Array(Array array)
|
||||
{
|
||||
Int32Array values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
values.Values.Add(item is null ? 0 : System.Convert.ToInt32(item, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static Int64Array ConvertInt64Array(Array array)
|
||||
{
|
||||
Int64Array values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
values.Values.Add(item is null ? 0 : System.Convert.ToInt64(item, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static FloatArray ConvertFloatArray(Array array)
|
||||
{
|
||||
FloatArray values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
values.Values.Add(item is null ? 0 : System.Convert.ToSingle(item, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static DoubleArray ConvertDoubleArray(Array array)
|
||||
{
|
||||
DoubleArray values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
values.Values.Add(item is null ? 0 : System.Convert.ToDouble(item, CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static StringArray ConvertStringArray(Array array)
|
||||
{
|
||||
StringArray values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
values.Values.Add(item is null ? string.Empty : System.Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty);
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static TimestampArray ConvertTimestampArray(Array array)
|
||||
{
|
||||
TimestampArray values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
if (item is null)
|
||||
{
|
||||
values.Values.Add(Timestamp.FromDateTime(new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc)));
|
||||
}
|
||||
else if (item is DateTime dateTime)
|
||||
{
|
||||
values.Values.Add(ToTimestamp(dateTime));
|
||||
}
|
||||
else
|
||||
{
|
||||
long fileTime = System.Convert.ToInt64(item, CultureInfo.InvariantCulture);
|
||||
values.Values.Add(Timestamp.FromDateTime(DateTime.FromFileTimeUtc(fileTime)));
|
||||
}
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static RawArray ConvertRawArray(Array array)
|
||||
{
|
||||
RawArray values = new();
|
||||
foreach (object? item in array)
|
||||
{
|
||||
string rawValue = item is null
|
||||
? string.Empty
|
||||
: System.Convert.ToString(item, CultureInfo.InvariantCulture) ?? string.Empty;
|
||||
values.Values.Add(ByteString.CopyFromUtf8(rawValue));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static MxDataType ResolveArrayElementDataType(
|
||||
System.Type? elementType,
|
||||
MxDataType expectedElementDataType)
|
||||
{
|
||||
if (expectedElementDataType != MxDataType.Unspecified)
|
||||
{
|
||||
return expectedElementDataType;
|
||||
}
|
||||
|
||||
if (elementType == typeof(bool))
|
||||
{
|
||||
return MxDataType.Boolean;
|
||||
}
|
||||
|
||||
if (elementType == typeof(byte)
|
||||
|| elementType == typeof(sbyte)
|
||||
|| elementType == typeof(short)
|
||||
|| elementType == typeof(ushort)
|
||||
|| elementType == typeof(int)
|
||||
|| elementType == typeof(uint)
|
||||
|| elementType == typeof(long)
|
||||
|| elementType == typeof(ulong))
|
||||
{
|
||||
return MxDataType.Integer;
|
||||
}
|
||||
|
||||
if (elementType == typeof(float))
|
||||
{
|
||||
return MxDataType.Float;
|
||||
}
|
||||
|
||||
if (elementType == typeof(double) || elementType == typeof(decimal))
|
||||
{
|
||||
return MxDataType.Double;
|
||||
}
|
||||
|
||||
if (elementType == typeof(string) || elementType == typeof(char))
|
||||
{
|
||||
return MxDataType.String;
|
||||
}
|
||||
|
||||
if (elementType == typeof(DateTime))
|
||||
{
|
||||
return MxDataType.Time;
|
||||
}
|
||||
|
||||
return MxDataType.Unknown;
|
||||
}
|
||||
|
||||
private static Timestamp ToTimestamp(DateTime dateTime)
|
||||
{
|
||||
DateTime utcDateTime = dateTime.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => dateTime,
|
||||
DateTimeKind.Local => dateTime.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(dateTime, DateTimeKind.Utc),
|
||||
};
|
||||
|
||||
return Timestamp.FromDateTime(utcDateTime);
|
||||
}
|
||||
|
||||
private static string CreateArrayVariantType(Array array)
|
||||
{
|
||||
System.Type? elementType = array.GetType().GetElementType();
|
||||
return $"SAFEARRAY({GetVariantTypeName(elementType)})";
|
||||
}
|
||||
|
||||
private static string GetVariantTypeName(System.Type? type)
|
||||
{
|
||||
if (type is null)
|
||||
{
|
||||
return "VT_EMPTY";
|
||||
}
|
||||
|
||||
System.Type nonNullableType = Nullable.GetUnderlyingType(type) ?? type;
|
||||
if (nonNullableType == typeof(bool))
|
||||
{
|
||||
return "VT_BOOL";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(byte))
|
||||
{
|
||||
return "VT_UI1";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(sbyte))
|
||||
{
|
||||
return "VT_I1";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(short))
|
||||
{
|
||||
return "VT_I2";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(ushort))
|
||||
{
|
||||
return "VT_UI2";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(int))
|
||||
{
|
||||
return "VT_I4";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(uint))
|
||||
{
|
||||
return "VT_UI4";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(long))
|
||||
{
|
||||
return "VT_I8";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(ulong))
|
||||
{
|
||||
return "VT_UI8";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(float))
|
||||
{
|
||||
return "VT_R4";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(double) || nonNullableType == typeof(decimal))
|
||||
{
|
||||
return "VT_R8";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(string) || nonNullableType == typeof(char))
|
||||
{
|
||||
return "VT_BSTR";
|
||||
}
|
||||
|
||||
if (nonNullableType == typeof(DateTime))
|
||||
{
|
||||
return "VT_DATE";
|
||||
}
|
||||
|
||||
return $"CLR:{nonNullableType.FullName}";
|
||||
}
|
||||
|
||||
private static string CreateRawDiagnostic(object value)
|
||||
{
|
||||
return $"Unsupported variant projection for CLR type '{value.GetType().FullName}'.";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>Manages the worker's named pipe connection to the gateway.</summary>
|
||||
public interface IWorkerPipeClient
|
||||
{
|
||||
/// <summary>Connects to the gateway and runs the worker until the session ends or is cancelled.</summary>
|
||||
/// <param name="options">Configuration options.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task RunAsync(
|
||||
WorkerOptions options,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
public static class WorkerContractInfo
|
||||
{
|
||||
/// <summary>The worker protocol version supported by this contract.</summary>
|
||||
public static uint SupportedProtocolVersion => GatewayContractInfo.WorkerProtocolVersion;
|
||||
|
||||
/// <summary>The fully qualified name of the WorkerEnvelope message descriptor.</summary>
|
||||
public static string WorkerEnvelopeDescriptorName => WorkerEnvelope.Descriptor.FullName;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>Validates worker envelope frames against protocol options.</summary>
|
||||
internal static class WorkerEnvelopeValidator
|
||||
{
|
||||
/// <summary>Validates a worker envelope for protocol compliance.</summary>
|
||||
/// <param name="envelope">The envelope to validate.</param>
|
||||
/// <param name="options">The frame protocol configuration.</param>
|
||||
public static void Validate(
|
||||
WorkerEnvelope envelope,
|
||||
WorkerFrameProtocolOptions options)
|
||||
{
|
||||
if (envelope.ProtocolVersion != options.ProtocolVersion)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.ProtocolVersionMismatch,
|
||||
$"Worker envelope protocol version {envelope.ProtocolVersion} does not match expected version {options.ProtocolVersion}.");
|
||||
}
|
||||
|
||||
if (!string.Equals(envelope.SessionId, options.SessionId, StringComparison.Ordinal))
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.SessionMismatch,
|
||||
"Worker envelope session id does not match the owning worker session.");
|
||||
}
|
||||
|
||||
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.None)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidEnvelope,
|
||||
"Worker envelope must include a typed body.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
public enum WorkerFrameProtocolErrorCode
|
||||
{
|
||||
Unknown = 0,
|
||||
InvalidConfiguration = 1,
|
||||
EndOfStream = 2,
|
||||
MalformedLength = 3,
|
||||
MessageTooLarge = 4,
|
||||
InvalidEnvelope = 5,
|
||||
ProtocolVersionMismatch = 6,
|
||||
SessionMismatch = 7,
|
||||
NonceMismatch = 8,
|
||||
UnexpectedEnvelopeBody = 9,
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Exception raised when the named-pipe frame protocol encounters an error.
|
||||
/// </summary>
|
||||
public sealed class WorkerFrameProtocolException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes with an error code and message.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">Protocol error classification.</param>
|
||||
/// <param name="message">Exception message.</param>
|
||||
public WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode errorCode,
|
||||
string message)
|
||||
: base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes with an error code, message, and inner exception.
|
||||
/// </summary>
|
||||
/// <param name="errorCode">Protocol error classification.</param>
|
||||
/// <param name="message">Exception message.</param>
|
||||
/// <param name="innerException">Underlying cause.</param>
|
||||
public WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode errorCode,
|
||||
string message,
|
||||
Exception innerException)
|
||||
: base(message, innerException)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The protocol error code classifying the failure.
|
||||
/// </summary>
|
||||
public WorkerFrameProtocolErrorCode ErrorCode { get; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>Configuration options for the worker frame protocol.</summary>
|
||||
public sealed class WorkerFrameProtocolOptions
|
||||
{
|
||||
/// <summary>Default maximum message size in bytes (16 MB).</summary>
|
||||
public const int DefaultMaxMessageBytes = 16 * 1024 * 1024;
|
||||
|
||||
/// <summary>Initializes a new instance of the WorkerFrameProtocolOptions class from WorkerOptions.</summary>
|
||||
/// <param name="options">Worker initialization options.</param>
|
||||
public WorkerFrameProtocolOptions(WorkerOptions options)
|
||||
: this(
|
||||
options?.SessionId ?? throw new ArgumentNullException(nameof(options)),
|
||||
options.ProtocolVersion,
|
||||
options.Nonce,
|
||||
DefaultMaxMessageBytes)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the WorkerFrameProtocolOptions class with default max message bytes.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="protocolVersion">Protocol version.</param>
|
||||
/// <param name="nonce">Nonce for startup validation.</param>
|
||||
public WorkerFrameProtocolOptions(
|
||||
string sessionId,
|
||||
uint protocolVersion,
|
||||
string nonce)
|
||||
: this(
|
||||
sessionId,
|
||||
protocolVersion,
|
||||
nonce,
|
||||
DefaultMaxMessageBytes)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the WorkerFrameProtocolOptions class with all parameters.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="protocolVersion">Protocol version.</param>
|
||||
/// <param name="nonce">Nonce for startup validation.</param>
|
||||
/// <param name="maxMessageBytes">Maximum message size in bytes.</param>
|
||||
public WorkerFrameProtocolOptions(
|
||||
string sessionId,
|
||||
uint protocolVersion,
|
||||
string nonce,
|
||||
int maxMessageBytes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidConfiguration,
|
||||
"Worker frame protocol requires a session id.");
|
||||
}
|
||||
|
||||
if (protocolVersion == 0)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidConfiguration,
|
||||
"Worker frame protocol requires a non-zero protocol version.");
|
||||
}
|
||||
|
||||
if (protocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.ProtocolVersionMismatch,
|
||||
$"Worker frame protocol version {protocolVersion} is not supported.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(nonce))
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidConfiguration,
|
||||
"Worker frame protocol requires a nonce.");
|
||||
}
|
||||
|
||||
if (maxMessageBytes <= 0)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidConfiguration,
|
||||
"Worker frame protocol max message size must be greater than zero.");
|
||||
}
|
||||
|
||||
SessionId = sessionId;
|
||||
ProtocolVersion = protocolVersion;
|
||||
Nonce = nonce;
|
||||
MaxMessageBytes = maxMessageBytes;
|
||||
}
|
||||
|
||||
/// <summary>Gets the session ID for the worker protocol.</summary>
|
||||
public string SessionId { get; }
|
||||
|
||||
/// <summary>Gets the protocol version.</summary>
|
||||
public uint ProtocolVersion { get; }
|
||||
|
||||
/// <summary>Gets the nonce for startup validation.</summary>
|
||||
public string Nonce { get; }
|
||||
|
||||
/// <summary>Gets the maximum message size in bytes.</summary>
|
||||
public int MaxMessageBytes { get; }
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
using System;
|
||||
using System.Buffers;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>Reads length-prefixed WorkerEnvelope protobuf frames from a stream.</summary>
|
||||
public sealed class WorkerFrameReader
|
||||
{
|
||||
private readonly WorkerFrameProtocolOptions _options;
|
||||
private readonly Stream _stream;
|
||||
|
||||
/// <summary>Initializes the reader with a stream and protocol options.</summary>
|
||||
/// <param name="stream">Stream to read frames from.</param>
|
||||
/// <param name="options">Protocol options for frame validation.</param>
|
||||
public WorkerFrameReader(
|
||||
Stream stream,
|
||||
WorkerFrameProtocolOptions options)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <summary>Reads and validates a single length-prefixed frame from the stream.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<WorkerEnvelope> ReadAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
byte[] lengthPrefix = new byte[sizeof(uint)];
|
||||
await ReadExactlyOrThrowAsync(lengthPrefix, lengthPrefix.Length, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
uint payloadLength = ReadUInt32LittleEndian(lengthPrefix);
|
||||
if (payloadLength == 0)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.MalformedLength,
|
||||
"Worker frame payload length must be greater than zero.");
|
||||
}
|
||||
|
||||
if (payloadLength > _options.MaxMessageBytes)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.MessageTooLarge,
|
||||
$"Worker frame payload length {payloadLength} exceeds the configured maximum of {_options.MaxMessageBytes} bytes.");
|
||||
}
|
||||
|
||||
// Rent the payload buffer from the shared pool rather than allocating
|
||||
// a fresh byte[] per frame. ParseFrom copies whatever it needs into
|
||||
// the parsed message, so the rented buffer can be returned as soon as
|
||||
// parsing completes.
|
||||
int length = checked((int)payloadLength);
|
||||
byte[] payload = ArrayPool<byte>.Shared.Rent(length);
|
||||
WorkerEnvelope envelope;
|
||||
try
|
||||
{
|
||||
await ReadExactlyOrThrowAsync(payload, length, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
envelope = WorkerEnvelope.Parser.ParseFrom(payload, 0, length);
|
||||
}
|
||||
catch (InvalidProtocolBufferException exception)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidEnvelope,
|
||||
"Worker frame payload is not a valid WorkerEnvelope protobuf message.",
|
||||
exception);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
ArrayPool<byte>.Shared.Return(payload);
|
||||
}
|
||||
|
||||
WorkerEnvelopeValidator.Validate(envelope, _options);
|
||||
|
||||
return envelope;
|
||||
}
|
||||
|
||||
private static uint ReadUInt32LittleEndian(byte[] buffer)
|
||||
{
|
||||
return (uint)buffer[0]
|
||||
| ((uint)buffer[1] << 8)
|
||||
| ((uint)buffer[2] << 16)
|
||||
| ((uint)buffer[3] << 24);
|
||||
}
|
||||
|
||||
private async Task ReadExactlyOrThrowAsync(
|
||||
byte[] buffer,
|
||||
int count,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
int offset = 0;
|
||||
while (offset < count)
|
||||
{
|
||||
int bytesRead = await _stream
|
||||
.ReadAsync(buffer, offset, count - offset, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (bytesRead == 0)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.EndOfStream,
|
||||
"Worker frame ended before the expected number of bytes were read.");
|
||||
}
|
||||
|
||||
offset += bytesRead;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Google.Protobuf;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>Writes worker frames to a stream with length-prefixed protobuf serialization.</summary>
|
||||
public sealed class WorkerFrameWriter
|
||||
{
|
||||
private readonly WorkerFrameProtocolOptions _options;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly Stream _stream;
|
||||
|
||||
/// <summary>Initializes a new instance of the WorkerFrameWriter class.</summary>
|
||||
/// <param name="stream">Stream to write frames to.</param>
|
||||
/// <param name="options">Protocol options for frame encoding.</param>
|
||||
public WorkerFrameWriter(
|
||||
Stream stream,
|
||||
WorkerFrameProtocolOptions options)
|
||||
{
|
||||
_stream = stream ?? throw new ArgumentNullException(nameof(stream));
|
||||
_options = options ?? throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
/// <summary>Writes a worker envelope frame to the stream with length prefix.</summary>
|
||||
/// <param name="envelope">Worker envelope to write.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public async Task WriteAsync(
|
||||
WorkerEnvelope envelope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (envelope is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(envelope));
|
||||
}
|
||||
|
||||
WorkerEnvelopeValidator.Validate(envelope, _options);
|
||||
|
||||
int payloadLength = envelope.CalculateSize();
|
||||
if (payloadLength == 0)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.InvalidEnvelope,
|
||||
"Worker envelope cannot serialize to an empty payload.");
|
||||
}
|
||||
|
||||
if (payloadLength > _options.MaxMessageBytes)
|
||||
{
|
||||
throw new WorkerFrameProtocolException(
|
||||
WorkerFrameProtocolErrorCode.MessageTooLarge,
|
||||
$"Worker envelope payload length {payloadLength} exceeds the configured maximum of {_options.MaxMessageBytes} bytes.");
|
||||
}
|
||||
|
||||
// Serialize once into a single buffer that carries the 4-byte
|
||||
// length prefix followed by the payload, then issue one stream write.
|
||||
// This avoids a second serialization pass (envelope.ToByteArray()
|
||||
// would re-run CalculateSize internally), a separate prefix array,
|
||||
// and a separate prefix write.
|
||||
int frameLength = sizeof(uint) + payloadLength;
|
||||
byte[] frame = new byte[frameLength];
|
||||
WriteUInt32LittleEndian(frame, (uint)payloadLength);
|
||||
envelope.WriteTo(new Span<byte>(frame, sizeof(uint), payloadLength));
|
||||
|
||||
await _writeLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await _stream.WriteAsync(frame, 0, frameLength, cancellationToken).ConfigureAwait(false);
|
||||
await _stream.FlushAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_writeLock.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static void WriteUInt32LittleEndian(
|
||||
byte[] buffer,
|
||||
uint value)
|
||||
{
|
||||
buffer[0] = (byte)value;
|
||||
buffer[1] = (byte)(value >> 8);
|
||||
buffer[2] = (byte)(value >> 16);
|
||||
buffer[3] = (byte)(value >> 24);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.IO.Pipes;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the gateway via a named pipe and runs the worker frame protocol session.
|
||||
/// </summary>
|
||||
public sealed class WorkerPipeClient : IWorkerPipeClient
|
||||
{
|
||||
/// <summary>Default overall connection timeout in milliseconds.</summary>
|
||||
public const int DefaultConnectTimeoutMilliseconds = 30000;
|
||||
|
||||
/// <summary>Default per-attempt connection timeout in milliseconds.</summary>
|
||||
public const int DefaultConnectAttemptTimeoutMilliseconds = 2000;
|
||||
|
||||
/// <summary>Environment variable for overriding the per-attempt connection timeout.</summary>
|
||||
public const string ConnectAttemptTimeoutEnvironmentVariableName =
|
||||
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
|
||||
|
||||
private readonly int _connectTimeoutMilliseconds;
|
||||
private readonly int _connectAttemptTimeoutMilliseconds;
|
||||
private readonly Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> _sessionFactory;
|
||||
private readonly IWorkerLogger? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with default timeouts.
|
||||
/// </summary>
|
||||
public WorkerPipeClient()
|
||||
: this(null, DefaultConnectTimeoutMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with a logger and default timeouts.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public WorkerPipeClient(IWorkerLogger? logger)
|
||||
: this(logger, DefaultConnectTimeoutMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with a custom overall connect timeout.
|
||||
/// </summary>
|
||||
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
|
||||
public WorkerPipeClient(int connectTimeoutMilliseconds)
|
||||
: this(null, connectTimeoutMilliseconds)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with custom timeouts and a session factory.
|
||||
/// </summary>
|
||||
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
|
||||
/// <param name="sessionFactory">Factory creating the worker pipe session.</param>
|
||||
public WorkerPipeClient(
|
||||
int connectTimeoutMilliseconds,
|
||||
Func<Stream, WorkerFrameProtocolOptions, WorkerPipeSession> sessionFactory)
|
||||
: this(
|
||||
null,
|
||||
connectTimeoutMilliseconds,
|
||||
ResolveDefaultConnectAttemptTimeoutMilliseconds(),
|
||||
(stream, frameOptions, _) => sessionFactory(stream, frameOptions))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with a logger and custom overall timeout.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
|
||||
public WorkerPipeClient(
|
||||
IWorkerLogger? logger,
|
||||
int connectTimeoutMilliseconds)
|
||||
: this(
|
||||
logger,
|
||||
connectTimeoutMilliseconds,
|
||||
ResolveDefaultConnectAttemptTimeoutMilliseconds(),
|
||||
(stream, frameOptions, workerLogger) => new WorkerPipeSession(stream, frameOptions, workerLogger))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with logger, timeouts, and a session factory.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
|
||||
/// <param name="sessionFactory">Factory creating the worker pipe session.</param>
|
||||
public WorkerPipeClient(
|
||||
IWorkerLogger? logger,
|
||||
int connectTimeoutMilliseconds,
|
||||
Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> sessionFactory)
|
||||
: this(
|
||||
logger,
|
||||
connectTimeoutMilliseconds,
|
||||
ResolveDefaultConnectAttemptTimeoutMilliseconds(),
|
||||
sessionFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a worker pipe client with full configuration.
|
||||
/// </summary>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
|
||||
/// <param name="connectAttemptTimeoutMilliseconds">Per-attempt connection timeout in milliseconds.</param>
|
||||
/// <param name="sessionFactory">Factory creating the worker pipe session.</param>
|
||||
public WorkerPipeClient(
|
||||
IWorkerLogger? logger,
|
||||
int connectTimeoutMilliseconds,
|
||||
int connectAttemptTimeoutMilliseconds,
|
||||
Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> sessionFactory)
|
||||
{
|
||||
if (connectTimeoutMilliseconds <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(connectTimeoutMilliseconds),
|
||||
"Worker pipe connect timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (connectAttemptTimeoutMilliseconds <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(connectAttemptTimeoutMilliseconds),
|
||||
"Worker pipe connect attempt timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
_logger = logger;
|
||||
_sessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory));
|
||||
_connectTimeoutMilliseconds = connectTimeoutMilliseconds;
|
||||
_connectAttemptTimeoutMilliseconds = connectAttemptTimeoutMilliseconds;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs the worker by connecting to the gateway and executing the frame protocol.
|
||||
/// </summary>
|
||||
/// <param name="options">Worker configuration options.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public async Task RunAsync(
|
||||
WorkerOptions options,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (options is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(options));
|
||||
}
|
||||
|
||||
WorkerFrameProtocolOptions frameOptions = new(options);
|
||||
|
||||
using NamedPipeClientStream pipe = await ConnectWithRetryAsync(options.PipeName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerPipeSession session = _sessionFactory(pipe, frameOptions, _logger);
|
||||
await session.RunAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<NamedPipeClientStream> ConnectWithRetryAsync(
|
||||
string pipeName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
// The real bound on connection attempts is the connectDeadline token
|
||||
// below (CancelAfter(connectTimeout)): Polly stops retrying as soon as
|
||||
// that token is cancelled. Driving retries purely off the deadline —
|
||||
// rather than a fragile attempt-count formula that ignored the
|
||||
// exponential backoff between attempts — keeps the time budget the
|
||||
// single source of truth. MaxRetryAttempts is set to its maximum so it
|
||||
// never ends the retry loop before the deadline does.
|
||||
ResiliencePipeline<NamedPipeClientStream> pipeline = new ResiliencePipelineBuilder<NamedPipeClientStream>()
|
||||
.AddRetry(new RetryStrategyOptions<NamedPipeClientStream>
|
||||
{
|
||||
MaxRetryAttempts = int.MaxValue,
|
||||
BackoffType = DelayBackoffType.Exponential,
|
||||
UseJitter = true,
|
||||
Delay = TimeSpan.FromMilliseconds(250),
|
||||
MaxDelay = TimeSpan.FromSeconds(2),
|
||||
ShouldHandle = new PredicateBuilder<NamedPipeClientStream>()
|
||||
.Handle<Exception>(exception => exception is TimeoutException or IOException),
|
||||
OnRetry = args =>
|
||||
{
|
||||
args.Outcome.Result?.Dispose();
|
||||
_logger?.Information(
|
||||
"WorkerPipeConnectRetry",
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["attempt"] = args.AttemptNumber + 1,
|
||||
["pipe_name"] = pipeName,
|
||||
});
|
||||
return default;
|
||||
},
|
||||
})
|
||||
.Build();
|
||||
|
||||
using CancellationTokenSource connectDeadline =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
connectDeadline.CancelAfter(_connectTimeoutMilliseconds);
|
||||
|
||||
try
|
||||
{
|
||||
return await pipeline.ExecuteAsync(
|
||||
async token => await ConnectSingleAttemptAsync(pipeName, token).ConfigureAwait(false),
|
||||
connectDeadline.Token)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException(
|
||||
$"Worker pipe {pipeName} did not connect within {_connectTimeoutMilliseconds}ms.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<NamedPipeClientStream> ConnectSingleAttemptAsync(
|
||||
string pipeName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
NamedPipeClientStream pipe = new(
|
||||
".",
|
||||
pipeName,
|
||||
PipeDirection.InOut,
|
||||
PipeOptions.Asynchronous);
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource attemptTimeout =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
attemptTimeout.CancelAfter(_connectAttemptTimeoutMilliseconds);
|
||||
|
||||
await Task.Run(
|
||||
() =>
|
||||
{
|
||||
attemptTimeout.Token.ThrowIfCancellationRequested();
|
||||
pipe.Connect(_connectAttemptTimeoutMilliseconds);
|
||||
},
|
||||
attemptTimeout.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return pipe;
|
||||
}
|
||||
catch
|
||||
{
|
||||
pipe.Dispose();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static int ResolveDefaultConnectAttemptTimeoutMilliseconds()
|
||||
{
|
||||
string? configuredValue = Environment.GetEnvironmentVariable(ConnectAttemptTimeoutEnvironmentVariableName);
|
||||
return int.TryParse(configuredValue, out int milliseconds) && milliseconds > 0
|
||||
? milliseconds
|
||||
: DefaultConnectAttemptTimeoutMilliseconds;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,88 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
/// <summary>Configuration options for worker pipe sessions including heartbeat parameters.</summary>
|
||||
public sealed class WorkerPipeSessionOptions
|
||||
{
|
||||
/// <summary>Default heartbeat interval (5 seconds).</summary>
|
||||
public static readonly TimeSpan DefaultHeartbeatInterval = TimeSpan.FromSeconds(5);
|
||||
/// <summary>Default heartbeat grace period (15 seconds).</summary>
|
||||
public static readonly TimeSpan DefaultHeartbeatGrace = TimeSpan.FromSeconds(15);
|
||||
/// <summary>
|
||||
/// Default defensive ceiling beyond which the watchdog fires
|
||||
/// <see cref="ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerFaultCategory.StaHung"/>
|
||||
/// even while a command is in flight (75 seconds = 5 ×
|
||||
/// <see cref="DefaultHeartbeatGrace"/>). See <see cref="HeartbeatStuckCeiling"/>
|
||||
/// for the rationale.
|
||||
/// </summary>
|
||||
public static readonly TimeSpan DefaultHeartbeatStuckCeiling = TimeSpan.FromSeconds(75);
|
||||
|
||||
/// <summary>Initializes a new instance of the WorkerPipeSessionOptions class with default values.</summary>
|
||||
public WorkerPipeSessionOptions()
|
||||
{
|
||||
HeartbeatInterval = DefaultHeartbeatInterval;
|
||||
HeartbeatGrace = DefaultHeartbeatGrace;
|
||||
HeartbeatStuckCeiling = DefaultHeartbeatStuckCeiling;
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the heartbeat interval.</summary>
|
||||
public TimeSpan HeartbeatInterval { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the heartbeat grace period.</summary>
|
||||
public TimeSpan HeartbeatGrace { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the defensive upper bound on how long the watchdog
|
||||
/// will suppress its <c>StaHung</c> fault while a command is in
|
||||
/// flight. Worker-017 suppresses the watchdog when the heartbeat
|
||||
/// snapshot's <c>CurrentCommandCorrelationId</c> is non-empty so a
|
||||
/// legitimately slow command (e.g. <c>ReadBulk</c> against many
|
||||
/// uncached tags) does not self-fault — but a truly stuck
|
||||
/// synchronous COM call against a dead MXAccess provider leaves
|
||||
/// <c>CurrentCommandCorrelationId</c> non-empty forever and would
|
||||
/// permanently defeat the watchdog. <c>HeartbeatStuckCeiling</c> is
|
||||
/// the upper bound on that suppression: once
|
||||
/// <c>LastStaActivityUtc</c> has been stale for longer than this
|
||||
/// ceiling, the watchdog DOES fire <c>StaHung</c> even with a
|
||||
/// command in flight, on the assumption that no legitimate STA
|
||||
/// command should run that long without periodically refreshing
|
||||
/// activity. Default is <see cref="DefaultHeartbeatStuckCeiling"/>
|
||||
/// (75 seconds = 5 × <see cref="DefaultHeartbeatGrace"/>); raise
|
||||
/// for deployments that run very long bulk operations.
|
||||
/// </summary>
|
||||
public TimeSpan HeartbeatStuckCeiling { get; set; }
|
||||
|
||||
/// <summary>Validates the session options.</summary>
|
||||
public void Validate()
|
||||
{
|
||||
if (HeartbeatInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(HeartbeatInterval),
|
||||
"Worker heartbeat interval must be greater than zero.");
|
||||
}
|
||||
|
||||
if (HeartbeatGrace <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(HeartbeatGrace),
|
||||
"Worker heartbeat grace must be greater than zero.");
|
||||
}
|
||||
|
||||
if (HeartbeatStuckCeiling <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(HeartbeatStuckCeiling),
|
||||
"Worker heartbeat stuck ceiling must be greater than zero.");
|
||||
}
|
||||
|
||||
if (HeartbeatStuckCeiling <= HeartbeatGrace)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(HeartbeatStuckCeiling),
|
||||
"Worker heartbeat stuck ceiling must be greater than HeartbeatGrace; "
|
||||
+ "otherwise it would fire before the in-flight-command suppression had any effect.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session owner of the worker's alarm-side state. Lazy-creates an
|
||||
/// <see cref="AlarmDispatcher"/> (with a wnwrap-backed
|
||||
/// <see cref="WnWrapAlarmConsumer"/> by default) on the first
|
||||
/// <see cref="Subscribe"/> call, then routes
|
||||
/// <see cref="Acknowledge"/> / <see cref="QueryActive"/> /
|
||||
/// <see cref="Unsubscribe"/> through the same instance for the
|
||||
/// session's lifetime.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Construction is dependency-injectable: the consumer factory
|
||||
/// (default <c>() => new WnWrapAlarmConsumer()</c>) lets tests
|
||||
/// substitute a fake without touching AVEVA COM. The event queue
|
||||
/// is supplied by the owning <see cref="MxAccessStaSession"/> so
|
||||
/// the alarm-side proto events land on the same queue the worker
|
||||
/// already drains for IPC dispatch.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threading: invoked from <see cref="MxAccessCommandExecutor"/>
|
||||
/// which runs on the STA. The wnwrap consumer owns no internal
|
||||
/// timer — the worker's STA drives <see cref="PollOnce"/> via
|
||||
/// <c>StaRuntime.InvokeAsync</c>, so the consumer's transition
|
||||
/// events fire on the same STA. The
|
||||
/// <see cref="AlarmDispatcher"/>'s event handler hands transitions
|
||||
/// into the thread-safe <see cref="MxAccessEventQueue"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
{
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly Func<IMxAccessAlarmConsumer> consumerFactory;
|
||||
private readonly Action? threadAffinityCheck;
|
||||
private readonly object syncRoot = new object();
|
||||
private AlarmDispatcher? dispatcher;
|
||||
private bool disposed;
|
||||
|
||||
public AlarmCommandHandler(MxAccessEventQueue eventQueue)
|
||||
: this(eventQueue, () => new WnWrapAlarmConsumer(), threadAffinityCheck: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Test seam — inject a custom consumer factory.</summary>
|
||||
public AlarmCommandHandler(
|
||||
MxAccessEventQueue eventQueue,
|
||||
Func<IMxAccessAlarmConsumer> consumerFactory)
|
||||
: this(eventQueue, consumerFactory, threadAffinityCheck: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-024: production constructor that also injects an
|
||||
/// STA-affinity guard. <paramref name="threadAffinityCheck"/> is
|
||||
/// invoked at the entry of every method that touches the underlying
|
||||
/// <see cref="IMxAccessAlarmConsumer"/> (or the wnwrap COM object
|
||||
/// through it) — <see cref="Subscribe"/>, <see cref="Unsubscribe"/>,
|
||||
/// <see cref="Acknowledge"/>, <see cref="AcknowledgeByName"/>,
|
||||
/// <see cref="QueryActive"/>, <see cref="PollOnce"/> — so an
|
||||
/// off-STA call raises a programming-error diagnostic instead of
|
||||
/// deadlocking on cross-apartment marshaling to the
|
||||
/// <c>ThreadingModel=Apartment</c> wnwrap CLSID. The guard is
|
||||
/// optional: tests that already drive the handler on a single
|
||||
/// thread can pass <c>null</c>.
|
||||
/// </summary>
|
||||
public AlarmCommandHandler(
|
||||
MxAccessEventQueue eventQueue,
|
||||
Func<IMxAccessAlarmConsumer> consumerFactory,
|
||||
Action? threadAffinityCheck)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.consumerFactory = consumerFactory ?? throw new ArgumentNullException(nameof(consumerFactory));
|
||||
this.threadAffinityCheck = threadAffinityCheck;
|
||||
}
|
||||
|
||||
public bool IsSubscribed
|
||||
{
|
||||
get { lock (syncRoot) return dispatcher is not null; }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(string subscription, string sessionId)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
||||
threadAffinityCheck?.Invoke();
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (dispatcher is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AlarmCommandHandler already has an active subscription; " +
|
||||
"call Unsubscribe before issuing another SubscribeAlarms command.");
|
||||
}
|
||||
IMxAccessAlarmConsumer consumer = consumerFactory()
|
||||
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
|
||||
MxAccessAlarmEventSink sink = new MxAccessAlarmEventSink(
|
||||
eventQueue, new MxAccessEventMapper());
|
||||
dispatcher = new AlarmDispatcher(consumer, sink, sessionId ?? string.Empty);
|
||||
try
|
||||
{
|
||||
dispatcher.Subscribe(subscription);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { dispatcher.Dispose(); } catch { /* swallow */ }
|
||||
dispatcher = null;
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unsubscribe()
|
||||
{
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? toDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
toDispose = dispatcher;
|
||||
dispatcher = null;
|
||||
}
|
||||
toDispose?.Dispose();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
{
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
return d.Acknowledge(
|
||||
alarmGuid,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName)
|
||||
{
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
return d.AcknowledgeByName(
|
||||
alarmName ?? string.Empty,
|
||||
providerName ?? string.Empty,
|
||||
groupName ?? string.Empty,
|
||||
comment ?? string.Empty,
|
||||
operatorUser ?? string.Empty,
|
||||
operatorNode ?? string.Empty,
|
||||
operatorDomain ?? string.Empty,
|
||||
operatorFullName ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix)
|
||||
{
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d = GetDispatcherOrThrow();
|
||||
IReadOnlyList<ActiveAlarmSnapshot> all = d.SnapshotActiveAlarms();
|
||||
if (string.IsNullOrEmpty(alarmFilterPrefix)) return all;
|
||||
List<ActiveAlarmSnapshot> filtered = new List<ActiveAlarmSnapshot>(all.Count);
|
||||
foreach (ActiveAlarmSnapshot snap in all)
|
||||
{
|
||||
if (snap.AlarmFullReference.StartsWith(alarmFilterPrefix!, StringComparison.Ordinal))
|
||||
{
|
||||
filtered.Add(snap);
|
||||
}
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void PollOnce()
|
||||
{
|
||||
threadAffinityCheck?.Invoke();
|
||||
AlarmDispatcher? d;
|
||||
lock (syncRoot) d = dispatcher;
|
||||
// No-op when not yet subscribed or already disposed.
|
||||
d?.PollOnce();
|
||||
}
|
||||
|
||||
private AlarmDispatcher GetDispatcherOrThrow()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmCommandHandler));
|
||||
AlarmDispatcher? d;
|
||||
lock (syncRoot) d = dispatcher;
|
||||
if (d is null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"AlarmCommandHandler has no active subscription; " +
|
||||
"call SubscribeAlarms before issuing alarm-related commands.");
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
Unsubscribe();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// In-process dispatcher that owns the lifetime of an
|
||||
/// <see cref="IMxAccessAlarmConsumer"/> + <see cref="MxAccessAlarmEventSink"/>
|
||||
/// pair, and wires the consumer's <c>AlarmTransitionEmitted</c> stream
|
||||
/// onto the sink's <c>EnqueueTransition</c> path so transitions land on
|
||||
/// the worker's <see cref="MxAccessEventQueue"/> as proto
|
||||
/// <see cref="OnAlarmTransitionEvent"/> messages ready for IPC dispatch.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The dispatcher carries the consumer→sink→queue pipeline. The
|
||||
/// worker's IPC layer issues <c>SubscribeAlarmsCommand</c> /
|
||||
/// <c>AcknowledgeAlarmCommand</c> / <c>QueryActiveAlarmsCommand</c>
|
||||
/// through <see cref="AlarmCommandHandler"/>, which owns one
|
||||
/// dispatcher per session.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Threading: <see cref="WnWrapAlarmConsumer"/> owns no internal
|
||||
/// timer — the worker's STA drives polling via
|
||||
/// <c>StaRuntime.InvokeAsync(() => PollOnce())</c>, so the
|
||||
/// consumer's <c>AlarmTransitionEmitted</c> event fires on the STA.
|
||||
/// The dispatcher is purely a pass-through, so it inherits that
|
||||
/// thread. Fan-out into <c>EnqueueTransition</c> uses the
|
||||
/// thread-safe <see cref="MxAccessEventQueue.Enqueue"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class AlarmDispatcher : IDisposable
|
||||
{
|
||||
private readonly IMxAccessAlarmConsumer consumer;
|
||||
private readonly MxAccessAlarmEventSink sink;
|
||||
private readonly string sessionId;
|
||||
private readonly EventHandler<MxAlarmTransitionEvent> handler;
|
||||
private bool disposed;
|
||||
|
||||
public AlarmDispatcher(
|
||||
IMxAccessAlarmConsumer consumer,
|
||||
MxAccessAlarmEventSink sink,
|
||||
string sessionId)
|
||||
{
|
||||
this.consumer = consumer ?? throw new ArgumentNullException(nameof(consumer));
|
||||
this.sink = sink ?? throw new ArgumentNullException(nameof(sink));
|
||||
this.sessionId = sessionId ?? string.Empty;
|
||||
// Sink.Attach is the seam that propagates the session id onto the
|
||||
// proto SessionId field of every emitted MxEvent. Pass the consumer
|
||||
// as the "associated COM object" — sink ignores the object reference
|
||||
// for the alarm path, but the existing IMxAccessEventSink contract
|
||||
// requires a non-null first arg.
|
||||
this.sink.Attach(this.consumer, this.sessionId);
|
||||
this.handler = OnTransition;
|
||||
consumer.AlarmTransitionEmitted += handler;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Begin polling the configured AVEVA alarm provider for
|
||||
/// transitions. The supplied subscription expression follows the
|
||||
/// canonical <c>\\<machine>\Galaxy!<area></c> format.
|
||||
/// </summary>
|
||||
public void Subscribe(string subscription)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
consumer.Subscribe(subscription);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Forward an <c>AcknowledgeAlarm</c> request to the underlying
|
||||
/// consumer's <c>AlarmAckByGUID</c>. Returns the AVEVA-native
|
||||
/// status code (0 = success).
|
||||
/// </summary>
|
||||
public int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
return consumer.AcknowledgeByGuid(
|
||||
alarmGuid,
|
||||
ackComment,
|
||||
ackOperatorName,
|
||||
ackOperatorNode,
|
||||
ackOperatorDomain,
|
||||
ackOperatorFullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge an alarm by its (name, provider, group) tuple.
|
||||
/// Routes to the consumer's <c>AcknowledgeByName</c> path which
|
||||
/// maps to <c>wwAlarmConsumerClass.AlarmAckByName</c>.
|
||||
/// </summary>
|
||||
public int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
return consumer.AcknowledgeByName(
|
||||
alarmName,
|
||||
providerName,
|
||||
groupName,
|
||||
ackComment,
|
||||
ackOperatorName,
|
||||
ackOperatorNode,
|
||||
ackOperatorDomain,
|
||||
ackOperatorFullName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single synchronous poll of the underlying consumer.
|
||||
/// Must be called on the STA thread that owns the wnwrap COM object.
|
||||
/// No-op if the dispatcher has been disposed.
|
||||
/// </summary>
|
||||
public void PollOnce()
|
||||
{
|
||||
if (disposed) return;
|
||||
consumer.PollOnce();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the currently-active alarm set as
|
||||
/// <see cref="ActiveAlarmSnapshot"/> protos for the
|
||||
/// <c>QueryActiveAlarms</c> RPC's ConditionRefresh stream.
|
||||
/// </summary>
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> SnapshotActiveAlarms()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> records = consumer.SnapshotActiveAlarms();
|
||||
if (records.Count == 0) return Array.Empty<ActiveAlarmSnapshot>();
|
||||
List<ActiveAlarmSnapshot> snapshots = new List<ActiveAlarmSnapshot>(records.Count);
|
||||
foreach (MxAlarmSnapshotRecord record in records)
|
||||
{
|
||||
snapshots.Add(MapToSnapshot(record));
|
||||
}
|
||||
return snapshots;
|
||||
}
|
||||
|
||||
private void OnTransition(object? sender, MxAlarmTransitionEvent transition)
|
||||
{
|
||||
if (disposed) return;
|
||||
if (transition is null) return;
|
||||
|
||||
MxAlarmSnapshotRecord record = transition.Record;
|
||||
AlarmTransitionKind kind = AlarmRecordTransitionMapper.MapTransition(
|
||||
transition.PreviousState, record.State);
|
||||
if (kind == AlarmTransitionKind.Unspecified) return;
|
||||
|
||||
string fullReference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
record.ProviderName, record.Group, record.TagName);
|
||||
|
||||
sink.EnqueueTransition(
|
||||
alarmFullReference: fullReference,
|
||||
sourceObjectReference: record.TagName,
|
||||
alarmTypeName: record.Type,
|
||||
transitionKind: kind,
|
||||
severity: record.Priority,
|
||||
originalRaiseTimestampUtc: null,
|
||||
transitionTimestampUtc: record.TransitionTimestampUtc,
|
||||
operatorUser: record.OperatorName,
|
||||
operatorComment: record.AlarmComment,
|
||||
category: record.Group,
|
||||
description: string.Empty);
|
||||
}
|
||||
|
||||
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record)
|
||||
{
|
||||
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = AlarmRecordTransitionMapper.ComposeFullReference(
|
||||
record.ProviderName, record.Group, record.TagName),
|
||||
SourceObjectReference = record.TagName,
|
||||
AlarmTypeName = record.Type,
|
||||
CurrentState = MapConditionState(record.State),
|
||||
Severity = record.Priority,
|
||||
OperatorUser = record.OperatorName,
|
||||
OperatorComment = record.AlarmComment,
|
||||
Category = record.Group,
|
||||
Description = string.Empty,
|
||||
};
|
||||
if (record.TransitionTimestampUtc != DateTime.MinValue)
|
||||
{
|
||||
snapshot.LastTransitionTimestamp =
|
||||
Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(record.TransitionTimestampUtc, DateTimeKind.Utc));
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static AlarmConditionState MapConditionState(MxAlarmStateKind state)
|
||||
{
|
||||
// The proto's AlarmConditionState only distinguishes Active /
|
||||
// ActiveAcked / Inactive — both Rtn states collapse to Inactive
|
||||
// (the ack-vs-unack distinction on a cleared alarm is not exposed
|
||||
// through OPC UA's Part 9 condition state model anyway).
|
||||
return state switch
|
||||
{
|
||||
MxAlarmStateKind.UnackAlm => AlarmConditionState.Active,
|
||||
MxAlarmStateKind.AckAlm => AlarmConditionState.ActiveAcked,
|
||||
MxAlarmStateKind.UnackRtn => AlarmConditionState.Inactive,
|
||||
MxAlarmStateKind.AckRtn => AlarmConditionState.Inactive,
|
||||
_ => AlarmConditionState.Unspecified,
|
||||
};
|
||||
}
|
||||
|
||||
public string SessionId => sessionId;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
try { consumer.AlarmTransitionEmitted -= handler; } catch { /* swallow */ }
|
||||
try { sink.Detach(); } catch { /* swallow */ }
|
||||
try { consumer.Dispose(); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Translation helpers between the wnwrapConsumer XML payload and the
|
||||
/// proto-friendly <see cref="AlarmTransitionKind"/> wire format, plus
|
||||
/// alarm-reference composition.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These mappings stay pure and library-agnostic so they're unit
|
||||
/// testable without an AVEVA install. The COM-side I/O lives on
|
||||
/// <see cref="WnWrapAlarmConsumer"/>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public static class AlarmRecordTransitionMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// Decode AVEVA's STATE string (one of <c>UNACK_ALM</c>, <c>ACK_ALM</c>,
|
||||
/// <c>UNACK_RTN</c>, <c>ACK_RTN</c>) into the worker's library-agnostic
|
||||
/// <see cref="MxAlarmStateKind"/>. Unknown values map to
|
||||
/// <see cref="MxAlarmStateKind.Unspecified"/>.
|
||||
/// </summary>
|
||||
public static MxAlarmStateKind ParseStateKind(string? stateXml)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(stateXml)) return MxAlarmStateKind.Unspecified;
|
||||
return stateXml!.Trim().ToUpperInvariant() switch
|
||||
{
|
||||
"UNACK_ALM" => MxAlarmStateKind.UnackAlm,
|
||||
"ACK_ALM" => MxAlarmStateKind.AckAlm,
|
||||
"UNACK_RTN" => MxAlarmStateKind.UnackRtn,
|
||||
"ACK_RTN" => MxAlarmStateKind.AckRtn,
|
||||
_ => MxAlarmStateKind.Unspecified,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decide which proto transition kind a state change represents.
|
||||
/// The decision table:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>previous=Unspecified</c> + <c>current=*Alm</c> → Raise (new alarm).</description></item>
|
||||
/// <item><description><c>previous=Unspecified</c> + <c>current=*Rtn</c> → Clear (alarm appeared in cleared state — rare; missed the raise).</description></item>
|
||||
/// <item><description><c>previous=Unack*</c> + <c>current=Ack*</c> → Acknowledge.</description></item>
|
||||
/// <item><description><c>previous=*Alm</c> + <c>current=*Rtn</c> → Clear.</description></item>
|
||||
/// <item><description><c>previous=*Rtn</c> + <c>current=*Alm</c> → Raise (re-trigger after clear).</description></item>
|
||||
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
public static AlarmTransitionKind MapTransition(
|
||||
MxAlarmStateKind previous,
|
||||
MxAlarmStateKind current)
|
||||
{
|
||||
if (current == MxAlarmStateKind.Unspecified) return AlarmTransitionKind.Unspecified;
|
||||
|
||||
bool currentIsAlm = current is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
|
||||
bool currentIsRtn = current is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
|
||||
bool currentIsAcked = current is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
|
||||
|
||||
if (previous == MxAlarmStateKind.Unspecified)
|
||||
{
|
||||
return currentIsAlm ? AlarmTransitionKind.Raise : AlarmTransitionKind.Clear;
|
||||
}
|
||||
|
||||
bool previousIsAlm = previous is MxAlarmStateKind.UnackAlm or MxAlarmStateKind.AckAlm;
|
||||
bool previousIsRtn = previous is MxAlarmStateKind.UnackRtn or MxAlarmStateKind.AckRtn;
|
||||
bool previousIsAcked = previous is MxAlarmStateKind.AckAlm or MxAlarmStateKind.AckRtn;
|
||||
|
||||
if (previousIsAlm && currentIsRtn) return AlarmTransitionKind.Clear;
|
||||
if (previousIsRtn && currentIsAlm) return AlarmTransitionKind.Raise;
|
||||
if (!previousIsAcked && currentIsAcked) return AlarmTransitionKind.Acknowledge;
|
||||
|
||||
return AlarmTransitionKind.Unspecified;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose <c>alarm_full_reference</c> as <c>Provider!Group.AlarmName</c>.
|
||||
/// The format mirrors AVEVA's standard alarm-reference syntax so
|
||||
/// downstream consumers that already speak it (e.g. the gateway's
|
||||
/// AcknowledgeAlarm RPC echoing a reference back as a GUID lookup)
|
||||
/// don't need translation.
|
||||
/// </summary>
|
||||
public static string ComposeFullReference(string? providerName, string? groupName, string? alarmName)
|
||||
{
|
||||
string provider = providerName ?? string.Empty;
|
||||
string group = groupName ?? string.Empty;
|
||||
string name = alarmName ?? string.Empty;
|
||||
if (string.IsNullOrEmpty(provider))
|
||||
{
|
||||
return string.IsNullOrEmpty(group) ? name : $"{group}.{name}";
|
||||
}
|
||||
return string.IsNullOrEmpty(group)
|
||||
? $"{provider}!{name}"
|
||||
: $"{provider}!{group}.{name}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reassemble a UTC <see cref="DateTime"/> from the wnwrap XML's
|
||||
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
||||
/// fields. Returns <see cref="DateTime.MinValue"/> when DATE / TIME
|
||||
/// can't be parsed (best-effort — failure is non-fatal; the proto
|
||||
/// will carry the epoch and the EventQueue's fault counter records
|
||||
/// the parse miss).
|
||||
/// </summary>
|
||||
/// <param name="xmlDate">e.g. <c>"2026/5/1"</c> (no zero-padding).</param>
|
||||
/// <param name="xmlTime">e.g. <c>"13:26:14.709"</c>.</param>
|
||||
/// <param name="gmtOffsetMinutes">Offset of the producer's local time vs UTC, in minutes.</param>
|
||||
/// <param name="dstAdjustMinutes">DST adjustment already applied to local time, in minutes.</param>
|
||||
public static DateTime ParseTransitionTimestampUtc(
|
||||
string? xmlDate,
|
||||
string? xmlTime,
|
||||
int gmtOffsetMinutes,
|
||||
int dstAdjustMinutes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(xmlDate) || string.IsNullOrWhiteSpace(xmlTime))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
// Parse DATE: yyyy/M/d (no zero padding observed). Use ParseExact with
|
||||
// multiple format candidates — AVEVA's locale may format differently
|
||||
// on non-en-US hosts.
|
||||
string[] dateFormats =
|
||||
{
|
||||
"yyyy/M/d", "yyyy/MM/dd", "M/d/yyyy", "MM/dd/yyyy",
|
||||
"d/M/yyyy", "dd/MM/yyyy",
|
||||
};
|
||||
string dateTrim = xmlDate!.Trim();
|
||||
if (!DateTime.TryParseExact(
|
||||
dateTrim,
|
||||
dateFormats,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out DateTime date))
|
||||
{
|
||||
if (!DateTime.TryParse(
|
||||
dateTrim,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out date))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse TIME: H:m:s.fff (variable precision).
|
||||
string[] timeFormats =
|
||||
{
|
||||
"H:m:s.fff", "H:m:s.ff", "H:m:s.f", "H:m:s",
|
||||
"HH:mm:ss.fff", "HH:mm:ss.ff", "HH:mm:ss.f", "HH:mm:ss",
|
||||
};
|
||||
string timeTrim = xmlTime!.Trim();
|
||||
if (!DateTime.TryParseExact(
|
||||
timeTrim,
|
||||
timeFormats,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out DateTime time))
|
||||
{
|
||||
if (!DateTime.TryParse(
|
||||
timeTrim,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
System.Globalization.DateTimeStyles.None,
|
||||
out time))
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
DateTime localProducerTime = new DateTime(
|
||||
date.Year, date.Month, date.Day,
|
||||
time.Hour, time.Minute, time.Second, time.Millisecond,
|
||||
DateTimeKind.Unspecified);
|
||||
|
||||
// GMTOFFSET = minutes east of UTC (or behind, depending on convention).
|
||||
// The wnwrap convention observed: GMTOFFSET=240, DSTADJUST=0 for
|
||||
// EDT (UTC-4) — so the field is "minutes from local to UTC". To get
|
||||
// UTC, ADD the offset.
|
||||
DateTime utc = localProducerTime.AddMinutes(gmtOffsetMinutes - dstAdjustMinutes);
|
||||
return DateTime.SpecifyKind(utc, DateTimeKind.Utc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session interface routing the worker's alarm IPC commands —
|
||||
/// <c>SubscribeAlarmsCommand</c>, <c>AcknowledgeAlarmCommand</c>,
|
||||
/// <c>QueryActiveAlarmsCommand</c>, <c>UnsubscribeAlarmsCommand</c> —
|
||||
/// to the underlying <see cref="AlarmDispatcher"/>. Production binding
|
||||
/// is <see cref="AlarmCommandHandler"/>; tests substitute a fake.
|
||||
/// </summary>
|
||||
public interface IAlarmCommandHandler : IDisposable
|
||||
{
|
||||
/// <summary>Begin a subscription against the supplied AVEVA alarm-provider expression.</summary>
|
||||
void Subscribe(string subscription, string sessionId);
|
||||
|
||||
/// <summary>Tear down the active subscription. No-op if not subscribed.</summary>
|
||||
void Unsubscribe();
|
||||
|
||||
/// <summary>Acknowledge a single alarm by GUID. Returns AVEVA's native status (0 = success).</summary>
|
||||
int Acknowledge(
|
||||
Guid alarmGuid,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a single alarm by (name, provider, group) — used when
|
||||
/// the caller has the human-readable reference but not the GUID.
|
||||
/// </summary>
|
||||
int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string comment,
|
||||
string operatorUser,
|
||||
string operatorNode,
|
||||
string operatorDomain,
|
||||
string operatorFullName);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot the currently-active alarm set, optionally scoped to a
|
||||
/// prefix matched against <c>AlarmFullReference</c>.
|
||||
/// </summary>
|
||||
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single poll of the underlying alarm consumer on the
|
||||
/// caller's thread. This is a no-op when there is no active
|
||||
/// subscription. In production the caller is the worker's STA
|
||||
/// (marshalled via <c>StaRuntime.InvokeAsync</c>), which satisfies
|
||||
/// the <c>ThreadingModel=Apartment</c> requirement of
|
||||
/// <c>wwAlarmConsumerClass</c>.
|
||||
/// </summary>
|
||||
void PollOnce();
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Abstraction over an AVEVA alarm-consumer COM library. The production
|
||||
/// implementation (<see cref="WnWrapAlarmConsumer"/>) wraps
|
||||
/// <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> from
|
||||
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>;
|
||||
/// tests substitute a fake to drive transition events without a live
|
||||
/// Galaxy.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The receive surface is poll-based: the production consumer
|
||||
/// periodically calls <c>GetXmlCurrentAlarms2</c>, parses the
|
||||
/// returned XML payload, diffs against the previous snapshot keyed
|
||||
/// by alarm GUID, and raises <see cref="AlarmTransitionEmitted"/>
|
||||
/// once per state change. This bypasses the FILETIME marshaling
|
||||
/// crash in <c>aaAlarmManagedClient.AlarmClient.GetHighPriAlarm</c>
|
||||
/// (see <c>docs/AlarmClientDiscovery.md</c>) — XML strings carry
|
||||
/// timestamps as ASCII fields, no DateTime auto-conversion happens
|
||||
/// on the .NET interop boundary.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public interface IMxAccessAlarmConsumer : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Fires once per detected alarm-state transition (raise, acknowledge,
|
||||
/// clear, or new-alarm-already-acked-on-arrival). Subscribers are
|
||||
/// expected to translate the record into the proto family
|
||||
/// <c>OnAlarmTransition</c> and enqueue it. Fired on the consumer's
|
||||
/// polling thread (the worker's STA in production); subscribers that
|
||||
/// need a different thread must marshal back themselves.
|
||||
/// </summary>
|
||||
event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the AVEVA alarm-client connection, registers as a
|
||||
/// consumer, and subscribes to the supplied alarm-provider expression.
|
||||
/// Subscription string follows AVEVA's canonical format:
|
||||
/// <c>\\<node>\Galaxy!<area></c>. The literal "Galaxy" is
|
||||
/// the provider name (regardless of the configured Galaxy database
|
||||
/// name). Subscribe does not start any polling of its own; the caller
|
||||
/// drives polls explicitly via <see cref="PollOnce"/>.
|
||||
/// </summary>
|
||||
void Subscribe(string subscription);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges a single alarm with full operator-identity fidelity.
|
||||
/// Reaches AVEVA's native <c>AlarmAckByGUID</c>; operator
|
||||
/// user / node / domain / full-name and the comment land atomically
|
||||
/// with the ack transition in the alarm-history log.
|
||||
/// </summary>
|
||||
int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledge a single alarm by its (name, provider, group) tuple.
|
||||
/// Reaches AVEVA's <c>AlarmAckByName</c> on
|
||||
/// <c>wwAlarmConsumerClass</c>; same alarm-history outcome as
|
||||
/// <see cref="AcknowledgeByGuid"/>, used when the caller has the
|
||||
/// human-readable reference but not the canonical GUID.
|
||||
/// </summary>
|
||||
int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName);
|
||||
|
||||
/// <summary>
|
||||
/// Returns the consumer's most recently parsed snapshot of currently
|
||||
/// active alarms. Used by the gateway's QueryActiveAlarms (PR A.7)
|
||||
/// ConditionRefresh path — operator clients call this after reconnect
|
||||
/// to seed local Part 9 state.
|
||||
/// </summary>
|
||||
IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms();
|
||||
|
||||
/// <summary>
|
||||
/// Drives a single synchronous poll of the underlying alarm source.
|
||||
/// The production consumer owns no internal timer; the worker's STA
|
||||
/// drives polls via <c>StaRuntime.InvokeAsync</c>, satisfying the
|
||||
/// <c>ThreadingModel=Apartment</c> requirement of
|
||||
/// <c>wwAlarmConsumerClass</c>. Fake implementations should no-op.
|
||||
/// This method must be invoked on the thread that created the consumer
|
||||
/// (the worker's STA in production).
|
||||
/// </summary>
|
||||
void PollOnce();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public interface IMxAccessComObjectFactory
|
||||
{
|
||||
/// <summary>Creates an MXAccess COM object instance.</summary>
|
||||
/// <returns>The created COM object.</returns>
|
||||
object Create();
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public interface IMxAccessEventSink
|
||||
{
|
||||
/// <summary>Attaches the event sink to an MXAccess COM object.</summary>
|
||||
/// <param name="mxAccessComObject">The MXAccess COM object.</param>
|
||||
/// <param name="sessionId">The session ID.</param>
|
||||
void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId);
|
||||
|
||||
/// <summary>Detaches the event sink from the COM object.</summary>
|
||||
void Detach();
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public interface IMxAccessServer
|
||||
{
|
||||
/// <summary>Registers a client and returns a server handle.</summary>
|
||||
/// <param name="clientName">Name of the client requesting registration.</param>
|
||||
/// <returns>Server handle for subsequent operations.</returns>
|
||||
int Register(string clientName);
|
||||
|
||||
/// <summary>Unregisters a server handle.</summary>
|
||||
/// <param name="serverHandle">Server handle to unregister.</param>
|
||||
void Unregister(int serverHandle);
|
||||
|
||||
/// <summary>Adds an item to a server and returns an item handle.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemDefinition">Item definition string.</param>
|
||||
/// <returns>Item handle for the added item.</returns>
|
||||
int AddItem(
|
||||
int serverHandle,
|
||||
string itemDefinition);
|
||||
|
||||
/// <summary>Adds an item with context to a server and returns an item handle.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemDefinition">Item definition string.</param>
|
||||
/// <param name="itemContext">Item context string.</param>
|
||||
/// <returns>Item handle for the added item.</returns>
|
||||
int AddItem2(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string itemContext);
|
||||
|
||||
/// <summary>Removes an item from a server.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to remove.</param>
|
||||
void RemoveItem(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
/// <summary>Subscribes to change notifications for an item.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to subscribe to.</param>
|
||||
void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
/// <summary>Unsubscribes from change notifications for an item.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to unsubscribe from.</param>
|
||||
void UnAdvise(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
/// <summary>Subscribes to supervisory change notifications for an item.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to subscribe to.</param>
|
||||
void AdviseSupervisory(
|
||||
int serverHandle,
|
||||
int itemHandle);
|
||||
|
||||
/// <summary>Writes a value to an item.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to write to.</param>
|
||||
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
|
||||
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
|
||||
void Write(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object? value,
|
||||
int userId);
|
||||
|
||||
/// <summary>Writes a value with an explicit source timestamp to an item.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to write to.</param>
|
||||
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
|
||||
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
|
||||
/// <param name="userId">MXAccess user id (security classification) for the write.</param>
|
||||
void Write2(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object? value,
|
||||
object? timestamp,
|
||||
int userId);
|
||||
|
||||
/// <summary>Performs a secured/verified write to an item.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to write to.</param>
|
||||
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
|
||||
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
|
||||
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
|
||||
void WriteSecured(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
object? value);
|
||||
|
||||
/// <summary>Performs a secured/verified write with an explicit source timestamp.</summary>
|
||||
/// <param name="serverHandle">Server handle identifying the registration.</param>
|
||||
/// <param name="itemHandle">Item handle to write to.</param>
|
||||
/// <param name="currentUserId">MXAccess user id of the operator performing the write.</param>
|
||||
/// <param name="verifierUserId">MXAccess user id of the verifier authorizing the write.</param>
|
||||
/// <param name="value">COM-marshalable value to write; <see langword="null"/> writes an MXAccess null.</param>
|
||||
/// <param name="timestamp">COM-marshalable source timestamp for the write.</param>
|
||||
void WriteSecured2(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
object? value,
|
||||
object? timestamp);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the runtime session between the worker and the MXAccess COM instance on an STA thread.
|
||||
/// </summary>
|
||||
public interface IWorkerRuntimeSession : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Starts the session, creates the MXAccess COM object, and returns ready metadata.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="workerProcessId">ID of the worker process.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Asynchronous task returning worker readiness metadata.</returns>
|
||||
Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches an STA command to the MXAccess runtime and returns the reply.
|
||||
/// </summary>
|
||||
/// <param name="command">STA command to execute on the STA thread.</param>
|
||||
/// <returns>Asynchronous task returning the command reply.</returns>
|
||||
Task<MxCommandReply> DispatchAsync(StaCommand command);
|
||||
|
||||
/// <summary>
|
||||
/// Captures a heartbeat snapshot of the runtime state.
|
||||
/// </summary>
|
||||
WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat();
|
||||
|
||||
/// <summary>
|
||||
/// Drains up to the specified number of pending events from the queue.
|
||||
/// </summary>
|
||||
/// <param name="maxEvents">Maximum number of events to drain.</param>
|
||||
/// <returns>List of drained events.</returns>
|
||||
IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Drains a pending fault from the queue, if any.
|
||||
/// </summary>
|
||||
WorkerFault? DrainFault();
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a pending command by correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Correlation ID of the command to cancel.</param>
|
||||
/// <returns>True if the command was found and cancelled; otherwise, false.</returns>
|
||||
bool CancelCommand(string correlationId);
|
||||
|
||||
/// <summary>
|
||||
/// Requests a graceful shutdown of the session.
|
||||
/// </summary>
|
||||
void RequestShutdown();
|
||||
|
||||
/// <summary>
|
||||
/// Shuts down the session gracefully within the specified timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to allow for graceful shutdown.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Asynchronous task returning the shutdown result.</returns>
|
||||
Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public enum MxAccessAdviceKind
|
||||
{
|
||||
Plain = 1,
|
||||
Supervisory = 2,
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
using System;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Sink for native MxAccess alarm transitions. Bridges
|
||||
/// <see cref="WnWrapAlarmConsumer"/> to the worker's event queue,
|
||||
/// producing <see cref="OnAlarmTransitionEvent"/> messages via
|
||||
/// <see cref="MxAccessEventMapper.CreateOnAlarmTransition"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <see cref="AlarmDispatcher"/> owns the wire-up: it constructs the
|
||||
/// consumer/sink pair, calls <see cref="Attach"/> to propagate the
|
||||
/// session id, and subscribes the consumer's
|
||||
/// <see cref="IMxAccessAlarmConsumer.AlarmTransitionEmitted"/> event
|
||||
/// so each decoded transition reaches <see cref="EnqueueTransition"/>.
|
||||
/// The <see cref="Attach"/> method here carries only the session id —
|
||||
/// the alarm path needs no COM-event subscription of its own because
|
||||
/// the consumer already polls and raises transition events. The
|
||||
/// captured payload schema is described in
|
||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured".
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class MxAccessAlarmEventSink : IMxAccessEventSink
|
||||
{
|
||||
private readonly MxAccessEventMapper eventMapper;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private string sessionId = string.Empty;
|
||||
private bool attached;
|
||||
|
||||
public MxAccessAlarmEventSink()
|
||||
: this(new MxAccessEventQueue(), new MxAccessEventMapper())
|
||||
{
|
||||
}
|
||||
|
||||
public MxAccessAlarmEventSink(
|
||||
MxAccessEventQueue eventQueue,
|
||||
MxAccessEventMapper eventMapper)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Attach(object mxAccessComObject, string sessionId)
|
||||
{
|
||||
if (mxAccessComObject is null) throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||
this.sessionId = sessionId ?? string.Empty;
|
||||
|
||||
// The alarm path needs no COM-event subscription here: the wnwrap
|
||||
// consumer is polled by the worker's STA and raises transition events
|
||||
// that AlarmDispatcher routes into EnqueueTransition. Attach only
|
||||
// records the session id stamped onto every emitted MxEvent.
|
||||
attached = true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Detach()
|
||||
{
|
||||
if (!attached) return;
|
||||
attached = false;
|
||||
sessionId = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues a decoded alarm transition. The COM-side delegate registered
|
||||
/// in <see cref="Attach"/> calls this method once it pulls the alarm
|
||||
/// fields out of the MxAccess event payload. Exposed internal so unit
|
||||
/// tests can drive the proto build path without a real COM event
|
||||
/// source.
|
||||
/// </summary>
|
||||
internal void EnqueueTransition(
|
||||
string alarmFullReference,
|
||||
string sourceObjectReference,
|
||||
string alarmTypeName,
|
||||
AlarmTransitionKind transitionKind,
|
||||
int severity,
|
||||
DateTime? originalRaiseTimestampUtc,
|
||||
DateTime transitionTimestampUtc,
|
||||
string operatorUser,
|
||||
string operatorComment,
|
||||
string category,
|
||||
string description)
|
||||
{
|
||||
try
|
||||
{
|
||||
MxEvent mxEvent = eventMapper.CreateOnAlarmTransition(
|
||||
sessionId,
|
||||
alarmFullReference,
|
||||
sourceObjectReference,
|
||||
alarmTypeName,
|
||||
transitionKind,
|
||||
severity,
|
||||
originalRaiseTimestampUtc,
|
||||
transitionTimestampUtc,
|
||||
operatorUser,
|
||||
operatorComment,
|
||||
category,
|
||||
description,
|
||||
statuses: null);
|
||||
eventQueue.Enqueue(mxEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
eventQueue.RecordFault(new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
ExceptionType = exception.GetType().FullName ?? string.Empty,
|
||||
DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}",
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.MxaccessFailure,
|
||||
Message = "MXAccess alarm event conversion failed.",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
using Proto = ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>Sink for MXAccess COM events that converts them to protobuf format.</summary>
|
||||
public sealed class MxAccessBaseEventSink : IMxAccessEventSink
|
||||
{
|
||||
private readonly MxAccessEventMapper eventMapper;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly MxAccessValueCache valueCache;
|
||||
private LMXProxyServerClass? server;
|
||||
private string sessionId = string.Empty;
|
||||
|
||||
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with a default queue.</summary>
|
||||
public MxAccessBaseEventSink()
|
||||
: this(new MxAccessEventQueue())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with a provided queue.</summary>
|
||||
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
|
||||
public MxAccessBaseEventSink(MxAccessEventQueue eventQueue)
|
||||
: this(eventQueue, new MxAccessEventMapper(), new MxAccessValueCache())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the MxAccessBaseEventSink class with provided queue and mapper.</summary>
|
||||
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
|
||||
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
|
||||
public MxAccessBaseEventSink(
|
||||
MxAccessEventQueue eventQueue,
|
||||
MxAccessEventMapper eventMapper)
|
||||
: this(eventQueue, eventMapper, new MxAccessValueCache())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the MxAccessBaseEventSink class with
|
||||
/// provided queue, mapper, and a shared value cache. The cache is
|
||||
/// populated from every successful <c>OnDataChange</c> dispatch so the
|
||||
/// worker's ReadBulk executor can satisfy a "current value" request
|
||||
/// from an already-advised tag without touching the subscription.
|
||||
/// </summary>
|
||||
/// <param name="eventQueue">Queue for buffering converted MXAccess events.</param>
|
||||
/// <param name="eventMapper">Converter for MXAccess events to protobuf format.</param>
|
||||
/// <param name="valueCache">Per-session last-value cache shared with the MxAccessSession.</param>
|
||||
public MxAccessBaseEventSink(
|
||||
MxAccessEventQueue eventQueue,
|
||||
MxAccessEventMapper eventMapper,
|
||||
MxAccessValueCache valueCache)
|
||||
{
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.eventMapper = eventMapper ?? throw new ArgumentNullException(nameof(eventMapper));
|
||||
this.valueCache = valueCache ?? throw new ArgumentNullException(nameof(valueCache));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The last-value cache populated by this sink. Exposed so the
|
||||
/// MxAccessSession can share the same instance for ReadBulk lookups.
|
||||
/// </summary>
|
||||
public MxAccessValueCache ValueCache => valueCache;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Attach(
|
||||
object mxAccessComObject,
|
||||
string sessionId)
|
||||
{
|
||||
this.sessionId = sessionId ?? string.Empty;
|
||||
server = (LMXProxyServerClass)mxAccessComObject;
|
||||
server.OnDataChange += OnDataChange;
|
||||
server.OnWriteComplete += OnWriteComplete;
|
||||
server.OperationComplete += OperationComplete;
|
||||
server.OnBufferedDataChange += OnBufferedDataChange;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Detach()
|
||||
{
|
||||
if (server is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
server.OnDataChange -= OnDataChange;
|
||||
server.OnWriteComplete -= OnWriteComplete;
|
||||
server.OperationComplete -= OperationComplete;
|
||||
server.OnBufferedDataChange -= OnBufferedDataChange;
|
||||
server = null;
|
||||
sessionId = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the MXAccess <c>OnDataChange</c> COM event: converts the
|
||||
/// event arguments to a protobuf <see cref="Proto.MxEvent"/> and enqueues
|
||||
/// it. Subscribed to the COM object's event in <see cref="Attach"/>.
|
||||
/// Exposed <c>internal</c> so unit tests can drive the integrated
|
||||
/// sink → mapper → queue path without a live MXAccess COM event source.
|
||||
/// </summary>
|
||||
internal void OnDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
object pvItemValue,
|
||||
int pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
// Build the protobuf event once, enqueue it for the outbound stream, and
|
||||
// also publish it into the per-session value cache so ReadBulk can serve
|
||||
// it as a "current value" without re-advising. The cache update is the
|
||||
// ONLY new side effect — fail-fast on conversion still drops the event
|
||||
// through the same EnqueueEvent path as before.
|
||||
EnqueueEvent(
|
||||
() => eventMapper.CreateOnDataChange(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
pvItemValue,
|
||||
pwItemQuality,
|
||||
pftItemTimeStamp,
|
||||
statuses),
|
||||
mxEvent => valueCache.Set(hLMXServerHandle, phItemHandle, mxEvent));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the MXAccess <c>OnWriteComplete</c> COM event. Exposed
|
||||
/// <c>internal</c> as a unit-test seam; see <see cref="OnDataChange"/>.
|
||||
/// </summary>
|
||||
internal void OnWriteComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOnWriteComplete(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
statuses));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the MXAccess <c>OperationComplete</c> COM event. Exposed
|
||||
/// <c>internal</c> as a unit-test seam; see <see cref="OnDataChange"/>.
|
||||
/// </summary>
|
||||
internal void OperationComplete(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOperationComplete(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
statuses));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Handles the MXAccess <c>OnBufferedDataChange</c> COM event. Exposed
|
||||
/// <c>internal</c> as a unit-test seam; see <see cref="OnDataChange"/>.
|
||||
/// </summary>
|
||||
internal void OnBufferedDataChange(
|
||||
int hLMXServerHandle,
|
||||
int phItemHandle,
|
||||
MxDataType dtDataType,
|
||||
object pvItemValue,
|
||||
object pwItemQuality,
|
||||
object pftItemTimeStamp,
|
||||
ref MXSTATUS_PROXY[] pVars)
|
||||
{
|
||||
MXSTATUS_PROXY[] statuses = pVars;
|
||||
EnqueueEvent(() => eventMapper.CreateOnBufferedDataChange(
|
||||
sessionId,
|
||||
hLMXServerHandle,
|
||||
phItemHandle,
|
||||
(int)dtDataType,
|
||||
pvItemValue,
|
||||
pwItemQuality,
|
||||
pftItemTimeStamp,
|
||||
statuses));
|
||||
}
|
||||
|
||||
private void EnqueueEvent(Func<Proto.MxEvent> createEvent)
|
||||
{
|
||||
EnqueueEvent(createEvent, postPublish: null);
|
||||
}
|
||||
|
||||
private void EnqueueEvent(Func<Proto.MxEvent> createEvent, Action<Proto.MxEvent>? postPublish)
|
||||
{
|
||||
Proto.MxEvent mxEvent;
|
||||
try
|
||||
{
|
||||
mxEvent = createEvent();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
eventQueue.Enqueue(mxEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Two distinct failures land here, both intentionally fail-fast:
|
||||
// - A conversion failure from createEvent() — recorded here as an
|
||||
// MxaccessEventConversionFailed fault.
|
||||
// - An MxAccessEventQueueOverflowException from Enqueue when the
|
||||
// queue is at capacity. Per the fail-fast backpressure design
|
||||
// (docs/DesignDecisions.md) the event is dropped and the queue
|
||||
// has *already* self-recorded a QueueOverflow fault. Because
|
||||
// MxAccessEventQueue.RecordFault keeps only the first fault,
|
||||
// this catch's RecordFault call is then a deliberate near
|
||||
// no-op rather than a second, conflicting fault.
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
return;
|
||||
}
|
||||
|
||||
// Only publish to caches/observers after the event has cleared the
|
||||
// queue, so a queue overflow does not leak a "fresher" cached value
|
||||
// than what was actually shipped to the gateway.
|
||||
if (postPublish is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
postPublish(mxEvent);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
eventQueue.RecordFault(CreateEventConversionFault(exception));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private Proto.WorkerFault CreateEventConversionFault(Exception exception)
|
||||
{
|
||||
return new Proto.WorkerFault
|
||||
{
|
||||
Category = Proto.WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
ExceptionType = exception.GetType().FullName ?? string.Empty,
|
||||
DiagnosticMessage = $"{exception.GetType().FullName}: HRESULT 0x{unchecked((uint)exception.HResult):X8}",
|
||||
ProtocolStatus = new Proto.ProtocolStatus
|
||||
{
|
||||
Code = Proto.ProtocolStatusCode.MxaccessFailure,
|
||||
Message = "MXAccess event conversion failed.",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>Factory for creating MXAccess COM objects on the STA thread.</summary>
|
||||
public sealed class MxAccessComObjectFactory : IMxAccessComObjectFactory
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public object Create()
|
||||
{
|
||||
return new LMXProxyServerClass();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter exposing MXAccess COM object methods through the <see cref="IMxAccessServer"/>
|
||||
/// interface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The supplied object must implement the typed MXAccess COM interface contract.
|
||||
/// In production it is the <c>LMXProxyServerClass</c> RCW, which implements
|
||||
/// <see cref="ILMXProxyServer"/> / <see cref="ILMXProxyServer3"/> /
|
||||
/// <see cref="ILMXProxyServer4"/>. Tests substitute a typed fake that
|
||||
/// implements <see cref="IMxAccessServer"/> directly. The earlier late-bound
|
||||
/// <c>Type.InvokeMember</c> reflection fallback was removed: it bypassed the
|
||||
/// typed interface contract, boxed value-type handles on every call, and only
|
||||
/// ever served test doubles — a typed fake is the supported test seam now.
|
||||
/// </remarks>
|
||||
public sealed class MxAccessComServer : IMxAccessServer
|
||||
{
|
||||
private readonly object mxAccessComObject;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the adapter with the MXAccess COM object.
|
||||
/// </summary>
|
||||
/// <param name="mxAccessComObject">
|
||||
/// MXAccess COM object instance. Must implement either the typed
|
||||
/// <see cref="ILMXProxyServer"/> COM interface family (production) or
|
||||
/// <see cref="IMxAccessServer"/> directly (test fakes).
|
||||
/// </param>
|
||||
public MxAccessComServer(object mxAccessComObject)
|
||||
{
|
||||
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int Register(string clientName)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
return typedFake.Register(clientName);
|
||||
}
|
||||
|
||||
return AsProxyServer().Register(clientName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.Unregister(serverHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer().Unregister(serverHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AddItem(
|
||||
int serverHandle,
|
||||
string itemDefinition)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
return typedFake.AddItem(serverHandle, itemDefinition);
|
||||
}
|
||||
|
||||
return AsProxyServer().AddItem(serverHandle, itemDefinition);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AddItem2(
|
||||
int serverHandle,
|
||||
string itemDefinition,
|
||||
string itemContext)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
return typedFake.AddItem2(serverHandle, itemDefinition, itemContext);
|
||||
}
|
||||
|
||||
return AsProxyServer3().AddItem2(serverHandle, itemDefinition, itemContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void RemoveItem(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.RemoveItem(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer().RemoveItem(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Advise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.Advise(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer().Advise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void UnAdvise(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.UnAdvise(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer().UnAdvise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void AdviseSupervisory(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.AdviseSupervisory(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer4().AdviseSupervisory(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object? value,
|
||||
int userId)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.Write(serverHandle, itemHandle, value, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer().Write(serverHandle, itemHandle, value!, userId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Write2(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object? value,
|
||||
object? timestamp,
|
||||
int userId)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.Write2(serverHandle, itemHandle, value, timestamp, userId);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer4().Write2(serverHandle, itemHandle, value!, timestamp!, userId);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteSecured(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
object? value)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer().WriteSecured(serverHandle, itemHandle, currentUserId, verifierUserId, value!);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void WriteSecured2(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int currentUserId,
|
||||
int verifierUserId,
|
||||
object? value,
|
||||
object? timestamp)
|
||||
{
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
typedFake.WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value, timestamp);
|
||||
return;
|
||||
}
|
||||
|
||||
AsProxyServer4().WriteSecured2(serverHandle, itemHandle, currentUserId, verifierUserId, value!, timestamp!);
|
||||
}
|
||||
|
||||
private ILMXProxyServer AsProxyServer()
|
||||
{
|
||||
return mxAccessComObject as ILMXProxyServer
|
||||
?? throw new InvalidOperationException(
|
||||
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
|
||||
+ $"{nameof(ILMXProxyServer)} or {nameof(IMxAccessServer)}.");
|
||||
}
|
||||
|
||||
private ILMXProxyServer3 AsProxyServer3()
|
||||
{
|
||||
return mxAccessComObject as ILMXProxyServer3
|
||||
?? throw new InvalidOperationException(
|
||||
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
|
||||
+ $"{nameof(ILMXProxyServer3)} or {nameof(IMxAccessServer)}.");
|
||||
}
|
||||
|
||||
private ILMXProxyServer4 AsProxyServer4()
|
||||
{
|
||||
return mxAccessComObject as ILMXProxyServer4
|
||||
?? throw new InvalidOperationException(
|
||||
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
|
||||
+ $"{nameof(ILMXProxyServer4)} or {nameof(IMxAccessServer)}.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,873 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Executes MXAccess commands on an STA session.
|
||||
/// </summary>
|
||||
public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
{
|
||||
/// <summary>Default per-tag timeout used when <c>ReadBulkCommand.timeout_ms</c> is zero.</summary>
|
||||
internal static readonly TimeSpan DefaultReadBulkTimeout = TimeSpan.FromMilliseconds(1000);
|
||||
|
||||
private readonly MxAccessSession session;
|
||||
private readonly VariantConverter variantConverter;
|
||||
private readonly IAlarmCommandHandler? alarmCommandHandler;
|
||||
private readonly Action pumpStep;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a command executor with an MXAccess session.
|
||||
/// </summary>
|
||||
/// <param name="session">MXAccess session on the STA thread.</param>
|
||||
public MxAccessCommandExecutor(MxAccessSession session)
|
||||
: this(session, new VariantConverter(), alarmCommandHandler: null, pumpStep: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a command executor with an MXAccess session and a variant converter.
|
||||
/// </summary>
|
||||
/// <param name="session">MXAccess session on the STA thread.</param>
|
||||
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
|
||||
public MxAccessCommandExecutor(
|
||||
MxAccessSession session,
|
||||
VariantConverter variantConverter)
|
||||
: this(session, variantConverter, alarmCommandHandler: null, pumpStep: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a command executor with an MXAccess session, variant
|
||||
/// converter, and an alarm command handler. The alarm handler is
|
||||
/// optional — when null, alarm-side commands return an
|
||||
/// "alarm consumer not configured" diagnostic.
|
||||
/// </summary>
|
||||
public MxAccessCommandExecutor(
|
||||
MxAccessSession session,
|
||||
VariantConverter variantConverter,
|
||||
IAlarmCommandHandler? alarmCommandHandler)
|
||||
: this(session, variantConverter, alarmCommandHandler, pumpStep: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a command executor with an MXAccess session, variant
|
||||
/// converter, alarm command handler, and a Windows-message pump action.
|
||||
/// The pump action is invoked from inside <c>ReadBulk</c>'s wait loop so
|
||||
/// MXAccess COM events queued for this STA can be dispatched while the
|
||||
/// executor is still holding the thread. Pass <c>null</c> in tests where
|
||||
/// ReadBulk is exercised against a fake worker that pre-populates the
|
||||
/// value cache — the executor falls back to a no-op pump step.
|
||||
/// </summary>
|
||||
public MxAccessCommandExecutor(
|
||||
MxAccessSession session,
|
||||
VariantConverter variantConverter,
|
||||
IAlarmCommandHandler? alarmCommandHandler,
|
||||
Action? pumpStep)
|
||||
{
|
||||
this.session = session ?? throw new ArgumentNullException(nameof(session));
|
||||
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
||||
this.alarmCommandHandler = alarmCommandHandler;
|
||||
this.pumpStep = pumpStep ?? (static () => { });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Executes an MXAccess command and returns the reply.
|
||||
/// </summary>
|
||||
/// <param name="command">STA command to execute.</param>
|
||||
/// <returns>Command reply with result or error details.</returns>
|
||||
public MxCommandReply Execute(StaCommand command)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
return command.Kind switch
|
||||
{
|
||||
MxCommandKind.Register => ExecuteRegister(command),
|
||||
MxCommandKind.Unregister => ExecuteUnregister(command),
|
||||
MxCommandKind.AddItem => ExecuteAddItem(command),
|
||||
MxCommandKind.AddItem2 => ExecuteAddItem2(command),
|
||||
MxCommandKind.RemoveItem => ExecuteRemoveItem(command),
|
||||
MxCommandKind.Advise => ExecuteAdvise(command),
|
||||
MxCommandKind.UnAdvise => ExecuteUnAdvise(command),
|
||||
MxCommandKind.AdviseSupervisory => ExecuteAdviseSupervisory(command),
|
||||
MxCommandKind.Write => ExecuteWrite(command),
|
||||
MxCommandKind.Write2 => ExecuteWrite2(command),
|
||||
MxCommandKind.WriteSecured => ExecuteWriteSecured(command),
|
||||
MxCommandKind.WriteSecured2 => ExecuteWriteSecured2(command),
|
||||
MxCommandKind.AddItemBulk => ExecuteAddItemBulk(command),
|
||||
MxCommandKind.AdviseItemBulk => ExecuteAdviseItemBulk(command),
|
||||
MxCommandKind.RemoveItemBulk => ExecuteRemoveItemBulk(command),
|
||||
MxCommandKind.UnAdviseItemBulk => ExecuteUnAdviseItemBulk(command),
|
||||
MxCommandKind.SubscribeBulk => ExecuteSubscribeBulk(command),
|
||||
MxCommandKind.UnsubscribeBulk => ExecuteUnsubscribeBulk(command),
|
||||
MxCommandKind.WriteBulk => ExecuteWriteBulk(command),
|
||||
MxCommandKind.Write2Bulk => ExecuteWrite2Bulk(command),
|
||||
MxCommandKind.WriteSecuredBulk => ExecuteWriteSecuredBulk(command),
|
||||
MxCommandKind.WriteSecured2Bulk => ExecuteWriteSecured2Bulk(command),
|
||||
MxCommandKind.ReadBulk => ExecuteReadBulk(command),
|
||||
MxCommandKind.SubscribeAlarms => ExecuteSubscribeAlarms(command),
|
||||
MxCommandKind.UnsubscribeAlarms => ExecuteUnsubscribeAlarms(command),
|
||||
MxCommandKind.AcknowledgeAlarm => ExecuteAcknowledgeAlarm(command),
|
||||
MxCommandKind.AcknowledgeAlarmByName => ExecuteAcknowledgeAlarmByName(command),
|
||||
MxCommandKind.QueryActiveAlarms => ExecuteQueryActiveAlarms(command),
|
||||
_ => CreateInvalidRequestReply(command, $"Unsupported MXAccess command kind {command.Kind}."),
|
||||
};
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteRegister(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Register)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Register command payload is required.");
|
||||
}
|
||||
|
||||
int serverHandle = session.Register(command.Command.Register.ClientName);
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.ReturnValue = variantConverter.Convert(serverHandle);
|
||||
reply.Register = new RegisterReply
|
||||
{
|
||||
ServerHandle = serverHandle,
|
||||
};
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteUnregister(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Unregister)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Unregister command payload is required.");
|
||||
}
|
||||
|
||||
session.Unregister(command.Command.Unregister.ServerHandle);
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAddItem(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItem)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AddItem command payload is required.");
|
||||
}
|
||||
|
||||
AddItemCommand addItemCommand = command.Command.AddItem;
|
||||
int itemHandle = session.AddItem(
|
||||
addItemCommand.ServerHandle,
|
||||
addItemCommand.ItemDefinition);
|
||||
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.ReturnValue = variantConverter.Convert(itemHandle);
|
||||
reply.AddItem = new AddItemReply
|
||||
{
|
||||
ItemHandle = itemHandle,
|
||||
};
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAddItem2(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItem2)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AddItem2 command payload is required.");
|
||||
}
|
||||
|
||||
AddItem2Command addItem2Command = command.Command.AddItem2;
|
||||
int itemHandle = session.AddItem2(
|
||||
addItem2Command.ServerHandle,
|
||||
addItem2Command.ItemDefinition,
|
||||
addItem2Command.ItemContext);
|
||||
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.ReturnValue = variantConverter.Convert(itemHandle);
|
||||
reply.AddItem2 = new AddItem2Reply
|
||||
{
|
||||
ItemHandle = itemHandle,
|
||||
};
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteRemoveItem(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.RemoveItem)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "RemoveItem command payload is required.");
|
||||
}
|
||||
|
||||
RemoveItemCommand removeItemCommand = command.Command.RemoveItem;
|
||||
session.RemoveItem(
|
||||
removeItemCommand.ServerHandle,
|
||||
removeItemCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAdvise(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Advise)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Advise command payload is required.");
|
||||
}
|
||||
|
||||
AdviseCommand adviseCommand = command.Command.Advise;
|
||||
session.Advise(
|
||||
adviseCommand.ServerHandle,
|
||||
adviseCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteUnAdvise(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnAdvise)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "UnAdvise command payload is required.");
|
||||
}
|
||||
|
||||
UnAdviseCommand unAdviseCommand = command.Command.UnAdvise;
|
||||
session.UnAdvise(
|
||||
unAdviseCommand.ServerHandle,
|
||||
unAdviseCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAdviseSupervisory(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AdviseSupervisory)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AdviseSupervisory command payload is required.");
|
||||
}
|
||||
|
||||
AdviseSupervisoryCommand adviseSupervisoryCommand = command.Command.AdviseSupervisory;
|
||||
session.AdviseSupervisory(
|
||||
adviseSupervisoryCommand.ServerHandle,
|
||||
adviseSupervisoryCommand.ItemHandle);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWrite(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write command payload is required.");
|
||||
}
|
||||
|
||||
WriteCommand writeCommand = command.Command.Write;
|
||||
if (writeCommand.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write command value is required.");
|
||||
}
|
||||
|
||||
session.Write(
|
||||
writeCommand.ServerHandle,
|
||||
writeCommand.ItemHandle,
|
||||
variantConverter.ConvertToComValue(writeCommand.Value),
|
||||
writeCommand.UserId);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWrite2(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write2 command payload is required.");
|
||||
}
|
||||
|
||||
Write2Command write2Command = command.Command.Write2;
|
||||
if (write2Command.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write2 command value is required.");
|
||||
}
|
||||
|
||||
if (write2Command.TimestampValue is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write2 command timestamp value is required.");
|
||||
}
|
||||
|
||||
session.Write2(
|
||||
write2Command.ServerHandle,
|
||||
write2Command.ItemHandle,
|
||||
variantConverter.ConvertToComValue(write2Command.Value),
|
||||
variantConverter.ConvertToComValue(write2Command.TimestampValue),
|
||||
write2Command.UserId);
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteSecured(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured command payload is required.");
|
||||
}
|
||||
|
||||
WriteSecuredCommand writeSecuredCommand = command.Command.WriteSecured;
|
||||
if (writeSecuredCommand.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured command value is required.");
|
||||
}
|
||||
|
||||
session.WriteSecured(
|
||||
writeSecuredCommand.ServerHandle,
|
||||
writeSecuredCommand.ItemHandle,
|
||||
writeSecuredCommand.CurrentUserId,
|
||||
writeSecuredCommand.VerifierUserId,
|
||||
variantConverter.ConvertToComValue(writeSecuredCommand.Value));
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteSecured2(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured2 command payload is required.");
|
||||
}
|
||||
|
||||
WriteSecured2Command writeSecured2Command = command.Command.WriteSecured2;
|
||||
if (writeSecured2Command.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured2 command value is required.");
|
||||
}
|
||||
|
||||
if (writeSecured2Command.TimestampValue is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured2 command timestamp value is required.");
|
||||
}
|
||||
|
||||
session.WriteSecured2(
|
||||
writeSecured2Command.ServerHandle,
|
||||
writeSecured2Command.ItemHandle,
|
||||
writeSecured2Command.CurrentUserId,
|
||||
writeSecured2Command.VerifierUserId,
|
||||
variantConverter.ConvertToComValue(writeSecured2Command.Value),
|
||||
variantConverter.ConvertToComValue(writeSecured2Command.TimestampValue));
|
||||
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAddItemBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AddItemBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AddItemBulk command payload is required.");
|
||||
}
|
||||
|
||||
AddItemBulkCommand addItemBulkCommand = command.Command.AddItemBulk;
|
||||
return CreateBulkReply(
|
||||
command,
|
||||
session.AddItemBulk(addItemBulkCommand.ServerHandle, addItemBulkCommand.TagAddresses));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAdviseItemBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AdviseItemBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AdviseItemBulk command payload is required.");
|
||||
}
|
||||
|
||||
AdviseItemBulkCommand adviseItemBulkCommand = command.Command.AdviseItemBulk;
|
||||
return CreateBulkReply(
|
||||
command,
|
||||
session.AdviseItemBulk(adviseItemBulkCommand.ServerHandle, adviseItemBulkCommand.ItemHandles));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteRemoveItemBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.RemoveItemBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "RemoveItemBulk command payload is required.");
|
||||
}
|
||||
|
||||
RemoveItemBulkCommand removeItemBulkCommand = command.Command.RemoveItemBulk;
|
||||
return CreateBulkReply(
|
||||
command,
|
||||
session.RemoveItemBulk(removeItemBulkCommand.ServerHandle, removeItemBulkCommand.ItemHandles));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteUnAdviseItemBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnAdviseItemBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "UnAdviseItemBulk command payload is required.");
|
||||
}
|
||||
|
||||
UnAdviseItemBulkCommand unAdviseItemBulkCommand = command.Command.UnAdviseItemBulk;
|
||||
return CreateBulkReply(
|
||||
command,
|
||||
session.UnAdviseItemBulk(unAdviseItemBulkCommand.ServerHandle, unAdviseItemBulkCommand.ItemHandles));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteSubscribeBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "SubscribeBulk command payload is required.");
|
||||
}
|
||||
|
||||
SubscribeBulkCommand subscribeBulkCommand = command.Command.SubscribeBulk;
|
||||
return CreateBulkReply(
|
||||
command,
|
||||
session.SubscribeBulk(subscribeBulkCommand.ServerHandle, subscribeBulkCommand.TagAddresses));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteUnsubscribeBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnsubscribeBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "UnsubscribeBulk command payload is required.");
|
||||
}
|
||||
|
||||
UnsubscribeBulkCommand unsubscribeBulkCommand = command.Command.UnsubscribeBulk;
|
||||
return CreateBulkReply(
|
||||
command,
|
||||
session.UnsubscribeBulk(unsubscribeBulkCommand.ServerHandle, unsubscribeBulkCommand.ItemHandles));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteBulk command payload is required.");
|
||||
}
|
||||
|
||||
WriteBulkCommand writeBulkCommand = command.Command.WriteBulk;
|
||||
foreach (WriteBulkEntry entry in writeBulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteBulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.WriteBulk(
|
||||
writeBulkCommand.ServerHandle,
|
||||
writeBulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWrite2Bulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.Write2Bulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "Write2Bulk command payload is required.");
|
||||
}
|
||||
|
||||
Write2BulkCommand write2BulkCommand = command.Command.Write2Bulk;
|
||||
foreach (Write2BulkEntry entry in write2BulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"Write2Bulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
|
||||
if (entry.TimestampValue is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"Write2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.Write2Bulk(
|
||||
write2BulkCommand.ServerHandle,
|
||||
write2BulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteSecuredBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecuredBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecuredBulk command payload is required.");
|
||||
}
|
||||
|
||||
WriteSecuredBulkCommand writeSecuredBulkCommand = command.Command.WriteSecuredBulk;
|
||||
foreach (WriteSecuredBulkEntry entry in writeSecuredBulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteSecuredBulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.WriteSecuredBulk(
|
||||
writeSecuredBulkCommand.ServerHandle,
|
||||
writeSecuredBulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteWriteSecured2Bulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.WriteSecured2Bulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "WriteSecured2Bulk command payload is required.");
|
||||
}
|
||||
|
||||
WriteSecured2BulkCommand writeSecured2BulkCommand = command.Command.WriteSecured2Bulk;
|
||||
foreach (WriteSecured2BulkEntry entry in writeSecured2BulkCommand.Entries)
|
||||
{
|
||||
if (entry.Value is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its value.");
|
||||
}
|
||||
|
||||
if (entry.TimestampValue is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"WriteSecured2Bulk entry for item handle {entry.ItemHandle} is missing its timestamp value.");
|
||||
}
|
||||
}
|
||||
|
||||
return CreateBulkWriteReply(
|
||||
command,
|
||||
session.WriteSecured2Bulk(
|
||||
writeSecured2BulkCommand.ServerHandle,
|
||||
writeSecured2BulkCommand.Entries,
|
||||
value => variantConverter.ConvertToComValue(value)));
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteReadBulk(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.ReadBulk)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "ReadBulk command payload is required.");
|
||||
}
|
||||
|
||||
ReadBulkCommand readBulkCommand = command.Command.ReadBulk;
|
||||
TimeSpan timeout = readBulkCommand.TimeoutMs == 0
|
||||
? DefaultReadBulkTimeout
|
||||
: TimeSpan.FromMilliseconds(readBulkCommand.TimeoutMs);
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = session.ReadBulk(
|
||||
readBulkCommand.ServerHandle,
|
||||
readBulkCommand.TagAddresses,
|
||||
timeout,
|
||||
pumpStep);
|
||||
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
BulkReadReply bulkReply = new();
|
||||
bulkReply.Results.Add(results);
|
||||
reply.ReadBulk = bulkReply;
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteSubscribeAlarms(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.SubscribeAlarms)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "SubscribeAlarms command payload is required.");
|
||||
}
|
||||
if (alarmCommandHandler is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
"SubscribeAlarms requires an alarm command handler; the worker was constructed without one.");
|
||||
}
|
||||
|
||||
string subscription = command.Command.SubscribeAlarms.SubscriptionExpression ?? string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(subscription))
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "SubscribeAlarms.subscription_expression is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
alarmCommandHandler.Subscribe(subscription, command.SessionId);
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteUnsubscribeAlarms(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.UnsubscribeAlarms)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "UnsubscribeAlarms command payload is required.");
|
||||
}
|
||||
if (alarmCommandHandler is null)
|
||||
{
|
||||
// No handler configured — Unsubscribe is a no-op in that case;
|
||||
// it can't be in a subscribed state to begin with.
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
alarmCommandHandler.Unsubscribe();
|
||||
return CreateOkReply(command);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAcknowledgeAlarm(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AcknowledgeAlarmCommand)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AcknowledgeAlarm command payload is required.");
|
||||
}
|
||||
if (alarmCommandHandler is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
"AcknowledgeAlarm requires an alarm command handler; the worker was constructed without one.");
|
||||
}
|
||||
|
||||
AcknowledgeAlarmCommand payload = command.Command.AcknowledgeAlarmCommand;
|
||||
if (!Guid.TryParse(payload.AlarmGuid, out Guid alarmGuid))
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
$"AcknowledgeAlarm.alarm_guid is not a valid canonical GUID: '{payload.AlarmGuid}'.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int rc = alarmCommandHandler.Acknowledge(
|
||||
alarmGuid,
|
||||
payload.Comment,
|
||||
payload.OperatorUser,
|
||||
payload.OperatorNode,
|
||||
payload.OperatorDomain,
|
||||
payload.OperatorFullName);
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.Hresult = rc;
|
||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
||||
{
|
||||
NativeStatus = rc,
|
||||
};
|
||||
if (rc != 0)
|
||||
{
|
||||
reply.DiagnosticMessage = $"AVEVA AlarmAckByGUID returned non-zero status {rc}.";
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteAcknowledgeAlarmByName(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.AcknowledgeAlarmByNameCommand)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName command payload is required.");
|
||||
}
|
||||
if (alarmCommandHandler is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
"AcknowledgeAlarmByName requires an alarm command handler; the worker was constructed without one.");
|
||||
}
|
||||
|
||||
AcknowledgeAlarmByNameCommand payload = command.Command.AcknowledgeAlarmByNameCommand;
|
||||
if (string.IsNullOrWhiteSpace(payload.AlarmName))
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "AcknowledgeAlarmByName.alarm_name is required.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int rc = alarmCommandHandler.AcknowledgeByName(
|
||||
payload.AlarmName,
|
||||
payload.ProviderName,
|
||||
payload.GroupName,
|
||||
payload.Comment,
|
||||
payload.OperatorUser,
|
||||
payload.OperatorNode,
|
||||
payload.OperatorDomain,
|
||||
payload.OperatorFullName);
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.Hresult = rc;
|
||||
reply.AcknowledgeAlarm = new AcknowledgeAlarmReplyPayload
|
||||
{
|
||||
NativeStatus = rc,
|
||||
};
|
||||
if (rc != 0)
|
||||
{
|
||||
reply.DiagnosticMessage = $"AVEVA AlarmAckByName returned non-zero status {rc}.";
|
||||
}
|
||||
return reply;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private MxCommandReply ExecuteQueryActiveAlarms(StaCommand command)
|
||||
{
|
||||
if (command.Command.PayloadCase != MxCommand.PayloadOneofCase.QueryActiveAlarmsCommand)
|
||||
{
|
||||
return CreateInvalidRequestReply(command, "QueryActiveAlarms command payload is required.");
|
||||
}
|
||||
if (alarmCommandHandler is null)
|
||||
{
|
||||
return CreateInvalidRequestReply(
|
||||
command,
|
||||
"QueryActiveAlarms requires an alarm command handler; the worker was constructed without one.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IReadOnlyList<ActiveAlarmSnapshot> snapshots = alarmCommandHandler.QueryActive(
|
||||
command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix);
|
||||
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
|
||||
payload.Snapshots.AddRange(snapshots);
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
reply.QueryActiveAlarms = payload;
|
||||
return reply;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return CreateAlarmFailureReply(command, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateAlarmFailureReply(StaCommand command, Exception exception)
|
||||
{
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.MxaccessFailure,
|
||||
Message = exception.Message,
|
||||
},
|
||||
DiagnosticMessage = $"{exception.GetType().FullName}: {exception.Message}",
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateOkReply(StaCommand command)
|
||||
{
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
Hresult = 0,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateBulkReply(
|
||||
StaCommand command,
|
||||
IEnumerable<SubscribeResult> results)
|
||||
{
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
BulkSubscribeReply bulkReply = new();
|
||||
bulkReply.Results.Add(results);
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.AddItemBulk:
|
||||
reply.AddItemBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.AdviseItemBulk:
|
||||
reply.AdviseItemBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.RemoveItemBulk:
|
||||
reply.RemoveItemBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.UnAdviseItemBulk:
|
||||
reply.UnAdviseItemBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk:
|
||||
reply.SubscribeBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.UnsubscribeBulk:
|
||||
reply.UnsubscribeBulk = bulkReply;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported bulk command kind {command.Kind}.");
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateBulkWriteReply(
|
||||
StaCommand command,
|
||||
IEnumerable<BulkWriteResult> results)
|
||||
{
|
||||
MxCommandReply reply = CreateOkReply(command);
|
||||
BulkWriteReply bulkReply = new();
|
||||
bulkReply.Results.Add(results);
|
||||
|
||||
switch (command.Kind)
|
||||
{
|
||||
case MxCommandKind.WriteBulk:
|
||||
reply.WriteBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.Write2Bulk:
|
||||
reply.Write2Bulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.WriteSecuredBulk:
|
||||
reply.WriteSecuredBulk = bulkReply;
|
||||
break;
|
||||
case MxCommandKind.WriteSecured2Bulk:
|
||||
reply.WriteSecured2Bulk = bulkReply;
|
||||
break;
|
||||
default:
|
||||
throw new InvalidOperationException($"Unsupported bulk write command kind {command.Kind}.");
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateInvalidRequestReply(
|
||||
StaCommand command,
|
||||
string message)
|
||||
{
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.InvalidRequest,
|
||||
Message = message,
|
||||
},
|
||||
DiagnosticMessage = message,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>Thrown when the worker fails to instantiate the MXAccess COM object.</summary>
|
||||
public sealed class MxAccessCreationException : Exception
|
||||
{
|
||||
/// <summary>Initializes a new instance with diagnostic info from the inner exception.</summary>
|
||||
/// <param name="innerException">The exception that caused the creation failure.</param>
|
||||
public MxAccessCreationException(Exception innerException)
|
||||
: base(
|
||||
$"Failed to create MXAccess COM object {MxAccessInteropInfo.ComClassName} ({MxAccessInteropInfo.ProgId}).",
|
||||
innerException)
|
||||
{
|
||||
AttemptedProgId = MxAccessInteropInfo.ProgId;
|
||||
AttemptedClsid = MxAccessInteropInfo.Clsid;
|
||||
AttemptedComClassName = MxAccessInteropInfo.ComClassName;
|
||||
HResult = innerException.HResult;
|
||||
}
|
||||
|
||||
/// <summary>The ProgID that was attempted during COM instantiation.</summary>
|
||||
public string AttemptedProgId { get; }
|
||||
|
||||
/// <summary>The CLSID that was attempted during COM instantiation.</summary>
|
||||
public string AttemptedClsid { get; }
|
||||
|
||||
/// <summary>The COM class name that was attempted during instantiation.</summary>
|
||||
public string AttemptedComClassName { get; }
|
||||
|
||||
/// <summary>The captured HResult from the instantiation failure, or null if zero.</summary>
|
||||
public int? CapturedHResult => HResult == 0 ? null : HResult;
|
||||
|
||||
/// <summary>Wraps an exception in MxAccessCreationException if it is not already.</summary>
|
||||
/// <param name="exception">The exception to wrap.</param>
|
||||
/// <returns>An MxAccessCreationException wrapping the input exception.</returns>
|
||||
public static MxAccessCreationException From(Exception exception)
|
||||
{
|
||||
return exception is MxAccessCreationException creationException
|
||||
? creationException
|
||||
: new MxAccessCreationException(exception);
|
||||
}
|
||||
|
||||
/// <summary>Extracts the HResult from an exception, handling MXAccess and COM exceptions specially.</summary>
|
||||
/// <param name="exception">The exception to extract the HResult from.</param>
|
||||
/// <returns>The HResult value, or null if zero.</returns>
|
||||
public static int? ExtractHResult(Exception exception)
|
||||
{
|
||||
if (exception is MxAccessCreationException creationException)
|
||||
{
|
||||
return creationException.CapturedHResult;
|
||||
}
|
||||
|
||||
if (exception is COMException comException)
|
||||
{
|
||||
return comException.HResult;
|
||||
}
|
||||
|
||||
return exception.HResult == 0 ? null : exception.HResult;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>Maps MXAccess COM events to protobuf MxEvent messages.</summary>
|
||||
public sealed class MxAccessEventMapper
|
||||
{
|
||||
private readonly VariantConverter variantConverter;
|
||||
private readonly MxStatusProxyConverter statusProxyConverter;
|
||||
|
||||
/// <summary>Initializes a new instance of the MxAccessEventMapper class with default converters.</summary>
|
||||
public MxAccessEventMapper()
|
||||
: this(new VariantConverter(), new MxStatusProxyConverter())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>Initializes a new instance of the MxAccessEventMapper class with provided converters.</summary>
|
||||
/// <param name="variantConverter">Converter for MXAccess variant values to MxValue protobuf messages.</param>
|
||||
/// <param name="statusProxyConverter">Converter for MXAccess status arrays to MxStatusProxy protobuf messages.</param>
|
||||
public MxAccessEventMapper(
|
||||
VariantConverter variantConverter,
|
||||
MxStatusProxyConverter statusProxyConverter)
|
||||
{
|
||||
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
|
||||
this.statusProxyConverter = statusProxyConverter ?? throw new ArgumentNullException(nameof(statusProxyConverter));
|
||||
}
|
||||
|
||||
/// <summary>Creates an OnDataChange event from MXAccess COM event arguments.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="value">Item value received from MXAccess.</param>
|
||||
/// <param name="quality">Item quality code from MXAccess.</param>
|
||||
/// <param name="timestamp">Item timestamp from MXAccess.</param>
|
||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||
public MxEvent CreateOnDataChange(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
object? value,
|
||||
int quality,
|
||||
object? timestamp,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnDataChange,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.Value = variantConverter.Convert(value);
|
||||
mxEvent.Quality = quality;
|
||||
ApplySourceTimestamp(mxEvent, timestamp);
|
||||
mxEvent.OnDataChange = new OnDataChangeEvent();
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/// <summary>Creates an OnWriteComplete event from MXAccess COM event arguments.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||
public MxEvent CreateOnWriteComplete(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnWriteComplete,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.OnWriteComplete = new OnWriteCompleteEvent();
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/// <summary>Creates an OperationComplete event from MXAccess COM event arguments.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||
public MxEvent CreateOperationComplete(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OperationComplete,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.OperationComplete = new OperationCompleteEvent();
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates an OnAlarmTransition event from MXAccess alarm-event arguments.
|
||||
/// The worker's alarm path drives this method from
|
||||
/// <see cref="MxAccessAlarmEventSink.EnqueueTransition"/> once
|
||||
/// <see cref="AlarmDispatcher"/> decodes a transition raised by the
|
||||
/// wnwrap-backed <see cref="WnWrapAlarmConsumer"/>.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="alarmFullReference">Fully-qualified MxAccess alarm reference (e.g. "Tank01.Level.HiHi").</param>
|
||||
/// <param name="sourceObjectReference">Galaxy-side source object reference; empty when not bound to a Galaxy object.</param>
|
||||
/// <param name="alarmTypeName">MxAccess alarm-type qualifier (e.g. "AnalogLimitAlarm.HiHi").</param>
|
||||
/// <param name="transitionKind">Discriminator: Raise / Acknowledge / Clear / Retrigger.</param>
|
||||
/// <param name="severity">Raw MxAccess severity (kept on the native scale; lmxopcua maps to OPC UA 0-1000).</param>
|
||||
/// <param name="originalRaiseTimestampUtc">When the alarm originally entered active; null on retrigger.</param>
|
||||
/// <param name="transitionTimestampUtc">When this specific transition occurred.</param>
|
||||
/// <param name="operatorUser">Operator principal recorded by MxAccess on Acknowledge transitions; empty on raise/clear.</param>
|
||||
/// <param name="operatorComment">Operator-supplied comment recorded by MxAccess on Acknowledge transitions; empty on raise/clear.</param>
|
||||
/// <param name="category">Alarm taxonomy bucket from the Galaxy template.</param>
|
||||
/// <param name="description">Human-readable alarm description.</param>
|
||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||
public MxEvent CreateOnAlarmTransition(
|
||||
string sessionId,
|
||||
string alarmFullReference,
|
||||
string sourceObjectReference,
|
||||
string alarmTypeName,
|
||||
AlarmTransitionKind transitionKind,
|
||||
int severity,
|
||||
DateTime? originalRaiseTimestampUtc,
|
||||
DateTime transitionTimestampUtc,
|
||||
string operatorUser,
|
||||
string operatorComment,
|
||||
string category,
|
||||
string description,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnAlarmTransition,
|
||||
sessionId,
|
||||
serverHandle: 0,
|
||||
itemHandle: 0,
|
||||
statuses);
|
||||
|
||||
OnAlarmTransitionEvent body = new()
|
||||
{
|
||||
AlarmFullReference = alarmFullReference ?? string.Empty,
|
||||
SourceObjectReference = sourceObjectReference ?? string.Empty,
|
||||
AlarmTypeName = alarmTypeName ?? string.Empty,
|
||||
TransitionKind = transitionKind,
|
||||
Severity = severity,
|
||||
TransitionTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(transitionTimestampUtc, DateTimeKind.Utc)),
|
||||
OperatorUser = operatorUser ?? string.Empty,
|
||||
OperatorComment = operatorComment ?? string.Empty,
|
||||
Category = category ?? string.Empty,
|
||||
Description = description ?? string.Empty,
|
||||
};
|
||||
if (originalRaiseTimestampUtc is { } orts)
|
||||
{
|
||||
body.OriginalRaiseTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(
|
||||
DateTime.SpecifyKind(orts, DateTimeKind.Utc));
|
||||
}
|
||||
mxEvent.OnAlarmTransition = body;
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/// <summary>Creates an OnBufferedDataChange event from MXAccess COM event arguments.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="rawDataType">Raw MXAccess data type code for the buffered value.</param>
|
||||
/// <param name="value">Item value received from MXAccess.</param>
|
||||
/// <param name="quality">Array of quality values from MXAccess.</param>
|
||||
/// <param name="timestamp">Array of timestamp values from MXAccess.</param>
|
||||
/// <param name="statuses">Array of MxStatusProxy values from MXAccess.</param>
|
||||
public MxEvent CreateOnBufferedDataChange(
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
int rawDataType,
|
||||
object? value,
|
||||
object? quality,
|
||||
object? timestamp,
|
||||
Array? statuses)
|
||||
{
|
||||
MxDataType dataType = MapMxDataType(rawDataType);
|
||||
MxEvent mxEvent = CreateBaseEvent(
|
||||
MxEventFamily.OnBufferedDataChange,
|
||||
sessionId,
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
statuses);
|
||||
mxEvent.Value = variantConverter.Convert(value, dataType);
|
||||
mxEvent.OnBufferedDataChange = new OnBufferedDataChangeEvent
|
||||
{
|
||||
DataType = dataType,
|
||||
RawDataType = rawDataType,
|
||||
QualityValues = ConvertBufferedArray(quality, MxDataType.Integer),
|
||||
TimestampValues = ConvertBufferedArray(timestamp, MxDataType.Time),
|
||||
};
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
/// <summary>Maps a raw MXAccess data type code to the MxDataType enum.</summary>
|
||||
/// <param name="rawDataType">Raw MXAccess data type value to map.</param>
|
||||
/// <returns>The corresponding MxDataType enum value.</returns>
|
||||
public static MxDataType MapMxDataType(int rawDataType)
|
||||
{
|
||||
return rawDataType switch
|
||||
{
|
||||
-1 => MxDataType.Unknown,
|
||||
0 => MxDataType.NoData,
|
||||
1 => MxDataType.Boolean,
|
||||
2 => MxDataType.Integer,
|
||||
3 => MxDataType.Float,
|
||||
4 => MxDataType.Double,
|
||||
5 => MxDataType.String,
|
||||
6 => MxDataType.Time,
|
||||
7 => MxDataType.ElapsedTime,
|
||||
8 => MxDataType.ReferenceType,
|
||||
9 => MxDataType.StatusType,
|
||||
10 => MxDataType.Enum,
|
||||
11 => MxDataType.SecurityClassificationEnum,
|
||||
12 => MxDataType.DataQualityType,
|
||||
13 => MxDataType.QualifiedEnum,
|
||||
14 => MxDataType.QualifiedStruct,
|
||||
15 => MxDataType.InternationalizedString,
|
||||
16 => MxDataType.BigString,
|
||||
17 => MxDataType.End,
|
||||
_ => MxDataType.Unknown,
|
||||
};
|
||||
}
|
||||
|
||||
private MxEvent CreateBaseEvent(
|
||||
MxEventFamily family,
|
||||
string sessionId,
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
Array? statuses)
|
||||
{
|
||||
MxEvent mxEvent = new()
|
||||
{
|
||||
Family = family,
|
||||
SessionId = sessionId ?? string.Empty,
|
||||
ServerHandle = serverHandle,
|
||||
ItemHandle = itemHandle,
|
||||
};
|
||||
mxEvent.Statuses.Add(statusProxyConverter.ConvertMany(statuses));
|
||||
|
||||
return mxEvent;
|
||||
}
|
||||
|
||||
private void ApplySourceTimestamp(
|
||||
MxEvent mxEvent,
|
||||
object? timestamp)
|
||||
{
|
||||
MxValue convertedTimestamp = variantConverter.Convert(timestamp, MxDataType.Time);
|
||||
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.TimestampValue)
|
||||
{
|
||||
mxEvent.SourceTimestamp = convertedTimestamp.TimestampValue;
|
||||
return;
|
||||
}
|
||||
|
||||
// MXAccess fires OnDataChange with pftItemTimeStamp marshaled as a
|
||||
// VT_BSTR string (e.g. "3/26/2026 1:38:22.907 PM"), not a FILETIME or
|
||||
// a VT_DATE — so the variant converter classifies it as a plain
|
||||
// string and the timestamp would otherwise be dropped. Parse it here
|
||||
// so the source timestamp still reaches MxEvent. MXAccess formats the
|
||||
// string in the worker host's local time; see TryParseSourceTimestamp.
|
||||
if (convertedTimestamp.KindCase == MxValue.KindOneofCase.StringValue
|
||||
&& TryParseSourceTimestamp(convertedTimestamp.StringValue, out DateTime parsedUtc))
|
||||
{
|
||||
mxEvent.SourceTimestamp = Timestamp.FromDateTime(parsedUtc);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(convertedTimestamp.RawDiagnostic))
|
||||
{
|
||||
mxEvent.RawStatus = string.IsNullOrWhiteSpace(mxEvent.RawStatus)
|
||||
? convertedTimestamp.RawDiagnostic
|
||||
: $"{mxEvent.RawStatus}; {convertedTimestamp.RawDiagnostic}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an MXAccess <c>OnDataChange</c> timestamp string into a UTC
|
||||
/// <see cref="DateTime"/>. MXAccess delivers the value as a culture-
|
||||
/// formatted string rather than a FILETIME or VT_DATE, and formats it
|
||||
/// in the worker host's <em>local</em> time (verified empirically — a
|
||||
/// fast-changing tag's timestamp lands the host's UTC offset behind
|
||||
/// wall-clock UTC). The parsed value is therefore taken as local time
|
||||
/// and converted to UTC. Tries the worker host's culture first
|
||||
/// (MXAccess formats with the host locale), then the invariant culture.
|
||||
/// </summary>
|
||||
/// <param name="text">The MXAccess timestamp string.</param>
|
||||
/// <param name="utc">The parsed UTC timestamp on success.</param>
|
||||
/// <returns><see langword="true"/> when the string parsed successfully.</returns>
|
||||
internal static bool TryParseSourceTimestamp(string? text, out DateTime utc)
|
||||
{
|
||||
utc = default;
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const DateTimeStyles styles = DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal;
|
||||
if (DateTime.TryParse(text, CultureInfo.CurrentCulture, styles, out DateTime parsed)
|
||||
|| DateTime.TryParse(text, CultureInfo.InvariantCulture, styles, out parsed))
|
||||
{
|
||||
utc = DateTime.SpecifyKind(parsed, DateTimeKind.Utc);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private MxArray ConvertBufferedArray(
|
||||
object? value,
|
||||
MxDataType expectedElementDataType)
|
||||
{
|
||||
if (value is Array array)
|
||||
{
|
||||
return variantConverter.ConvertArray(array, expectedElementDataType);
|
||||
}
|
||||
|
||||
MxValue converted = variantConverter.Convert(value, expectedElementDataType);
|
||||
if (converted.KindCase == MxValue.KindOneofCase.ArrayValue)
|
||||
{
|
||||
return converted.ArrayValue;
|
||||
}
|
||||
|
||||
MxArray mxArray = new()
|
||||
{
|
||||
ElementDataType = converted.DataType,
|
||||
VariantType = converted.VariantType,
|
||||
RawElementDataType = converted.RawDataType,
|
||||
RawDiagnostic = string.IsNullOrWhiteSpace(converted.RawDiagnostic)
|
||||
? "Buffered MXAccess event argument was not a SAFEARRAY."
|
||||
: converted.RawDiagnostic,
|
||||
};
|
||||
|
||||
switch (converted.KindCase)
|
||||
{
|
||||
case MxValue.KindOneofCase.Int32Value:
|
||||
mxArray.Int32Values = new Int32Array();
|
||||
mxArray.Int32Values.Values.Add(converted.Int32Value);
|
||||
break;
|
||||
|
||||
case MxValue.KindOneofCase.Int64Value:
|
||||
mxArray.Int64Values = new Int64Array();
|
||||
mxArray.Int64Values.Values.Add(converted.Int64Value);
|
||||
break;
|
||||
|
||||
case MxValue.KindOneofCase.TimestampValue:
|
||||
mxArray.TimestampValues = new TimestampArray();
|
||||
mxArray.TimestampValues.Values.Add(converted.TimestampValue);
|
||||
break;
|
||||
}
|
||||
|
||||
return mxArray;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Thread-safe queue for MxAccess events with capacity overflow and fault tracking.
|
||||
/// </summary>
|
||||
public sealed class MxAccessEventQueue
|
||||
{
|
||||
/// <summary>
|
||||
/// Default queue capacity (10,000 events).
|
||||
/// </summary>
|
||||
public const int DefaultCapacity = 10000;
|
||||
|
||||
private readonly int capacity;
|
||||
private readonly Queue<WorkerEvent> events;
|
||||
private readonly object syncRoot = new();
|
||||
private ulong lastEventSequence;
|
||||
private WorkerFault? fault;
|
||||
private bool faultDrained;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the queue with the default capacity.
|
||||
/// </summary>
|
||||
public MxAccessEventQueue()
|
||||
: this(DefaultCapacity)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes the queue with the specified capacity.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Maximum number of events the queue can hold.</param>
|
||||
public MxAccessEventQueue(int capacity)
|
||||
{
|
||||
if (capacity <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(capacity),
|
||||
"MXAccess event queue capacity must be greater than zero.");
|
||||
}
|
||||
|
||||
this.capacity = capacity;
|
||||
events = new Queue<WorkerEvent>(capacity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The queue's maximum capacity.
|
||||
/// </summary>
|
||||
public int Capacity => capacity;
|
||||
|
||||
/// <summary>
|
||||
/// The current number of events in the queue.
|
||||
/// </summary>
|
||||
public int Count
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return events.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The highest event sequence number assigned.
|
||||
/// </summary>
|
||||
public ulong LastEventSequence
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return lastEventSequence;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indicates whether the queue is in a faulted state.
|
||||
/// </summary>
|
||||
public bool IsFaulted
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return fault is not null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The current fault if the queue is faulted, or null.
|
||||
/// </summary>
|
||||
public WorkerFault? Fault
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
return fault?.Clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enqueues an MxAccess event, assigning a sequence number and timestamp.
|
||||
/// </summary>
|
||||
/// <param name="mxEvent">MXAccess event to enqueue.</param>
|
||||
public void Enqueue(MxEvent mxEvent)
|
||||
{
|
||||
if (mxEvent is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mxEvent));
|
||||
}
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (fault is not null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess outbound event queue is faulted.");
|
||||
}
|
||||
|
||||
if (events.Count >= capacity)
|
||||
{
|
||||
fault = CreateOverflowFault();
|
||||
throw new MxAccessEventQueueOverflowException(capacity);
|
||||
}
|
||||
|
||||
MxEvent queuedEvent = mxEvent.Clone();
|
||||
queuedEvent.WorkerSequence = ++lastEventSequence;
|
||||
queuedEvent.WorkerTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
|
||||
|
||||
WorkerEvent workerEvent = new()
|
||||
{
|
||||
Event = queuedEvent,
|
||||
};
|
||||
events.Enqueue(workerEvent);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to dequeue the next event without removing it if empty.
|
||||
/// </summary>
|
||||
/// <param name="workerEvent">The dequeued event if successful; null if queue is empty.</param>
|
||||
public bool TryDequeue(out WorkerEvent? workerEvent)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (events.Count == 0)
|
||||
{
|
||||
workerEvent = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
workerEvent = events.Dequeue();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains up to maxEvents from the queue; if maxEvents is 0, drains all events.
|
||||
/// </summary>
|
||||
/// <param name="maxEvents">Maximum number of events to drain; 0 means drain all.</param>
|
||||
public IReadOnlyList<WorkerEvent> Drain(uint maxEvents)
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
int drainCount = maxEvents == 0
|
||||
? events.Count
|
||||
: Math.Min(events.Count, checked((int)Math.Min(maxEvents, int.MaxValue)));
|
||||
if (drainCount == 0)
|
||||
{
|
||||
return Array.Empty<WorkerEvent>();
|
||||
}
|
||||
|
||||
List<WorkerEvent> drained = new(drainCount);
|
||||
for (int index = 0; index < drainCount; index++)
|
||||
{
|
||||
drained.Add(events.Dequeue());
|
||||
}
|
||||
|
||||
return drained;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Records a fault if one has not already been recorded.
|
||||
/// </summary>
|
||||
/// <param name="workerFault">Worker fault to record.</param>
|
||||
public void RecordFault(WorkerFault workerFault)
|
||||
{
|
||||
if (workerFault is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(workerFault));
|
||||
}
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
fault ??= workerFault.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns and clears the fault so it is not reported twice.
|
||||
/// </summary>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (fault is null || faultDrained)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
faultDrained = true;
|
||||
return fault.Clone();
|
||||
}
|
||||
}
|
||||
|
||||
private WorkerFault CreateOverflowFault()
|
||||
{
|
||||
string message = $"MXAccess outbound event queue reached capacity {capacity}.";
|
||||
return new WorkerFault
|
||||
{
|
||||
Category = WorkerFaultCategory.QueueOverflow,
|
||||
DiagnosticMessage = message,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||
Message = message,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessEventQueueOverflowException : Exception
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessEventQueueOverflowException"/>.
|
||||
/// </summary>
|
||||
/// <param name="capacity">Queue capacity.</param>
|
||||
public MxAccessEventQueueOverflowException(int capacity)
|
||||
: base($"MXAccess outbound event queue reached its configured capacity of {capacity}.")
|
||||
{
|
||||
Capacity = capacity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queue capacity.
|
||||
/// </summary>
|
||||
public int Capacity { get; }
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessHandleRegistry
|
||||
{
|
||||
private readonly Dictionary<int, RegisteredServerHandle> serverHandles = new();
|
||||
private readonly Dictionary<long, RegisteredItemHandle> itemHandles = new();
|
||||
private readonly Dictionary<AdviceHandleKey, RegisteredAdviceHandle> adviceHandles = new();
|
||||
|
||||
/// <summary>Gets a read-only list of registered server handles ordered by handle value.</summary>
|
||||
public IReadOnlyList<RegisteredServerHandle> ServerHandles => serverHandles
|
||||
.Values
|
||||
.OrderBy(handle => handle.ServerHandle)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>Gets a read-only list of registered item handles ordered by server handle then item handle.</summary>
|
||||
public IReadOnlyList<RegisteredItemHandle> ItemHandles => itemHandles
|
||||
.Values
|
||||
.OrderBy(handle => handle.ServerHandle)
|
||||
.ThenBy(handle => handle.ItemHandle)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>Gets a read-only list of registered advice handles ordered by server handle, item handle, and advice kind.</summary>
|
||||
public IReadOnlyList<RegisteredAdviceHandle> AdviceHandles => adviceHandles
|
||||
.Values
|
||||
.OrderBy(handle => handle.ServerHandle)
|
||||
.ThenBy(handle => handle.ItemHandle)
|
||||
.ThenBy(handle => handle.AdviceKind)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>Registers a server handle with the registry.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="clientName">Display name of the client that owns the server handle.</param>
|
||||
public void RegisterServerHandle(
|
||||
int serverHandle,
|
||||
string clientName)
|
||||
{
|
||||
serverHandles[serverHandle] = new RegisteredServerHandle(serverHandle, clientName);
|
||||
}
|
||||
|
||||
/// <summary>Unregisters a server handle and all associated item and advice handles from the registry.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
public void UnregisterServerHandle(int serverHandle)
|
||||
{
|
||||
serverHandles.Remove(serverHandle);
|
||||
|
||||
foreach (long key in itemHandles
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
itemHandles.Remove(key);
|
||||
}
|
||||
|
||||
foreach (AdviceHandleKey key in adviceHandles
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
adviceHandles.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Checks if the registry contains the specified server handle.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
public bool ContainsServerHandle(int serverHandle)
|
||||
{
|
||||
return serverHandles.ContainsKey(serverHandle);
|
||||
}
|
||||
|
||||
/// <summary>Registers an item handle with the registry.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemDefinition">Item definition name from MXAccess.</param>
|
||||
/// <param name="itemContext">Item context from MXAccess, or empty string if none.</param>
|
||||
/// <param name="hasItemContext">True if the item has a context; false otherwise.</param>
|
||||
public void RegisterItemHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
bool hasItemContext)
|
||||
{
|
||||
itemHandles[CreateItemKey(serverHandle, itemHandle)] = new RegisteredItemHandle(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
itemDefinition,
|
||||
itemContext,
|
||||
hasItemContext);
|
||||
}
|
||||
|
||||
/// <summary>Removes an item handle and all associated advice handles from the registry.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
public void RemoveItemHandle(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
itemHandles.Remove(CreateItemKey(serverHandle, itemHandle));
|
||||
RemoveAdviceHandles(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <summary>Checks if the registry contains the specified item handle.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
public bool ContainsItemHandle(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return itemHandles.ContainsKey(CreateItemKey(serverHandle, itemHandle));
|
||||
}
|
||||
|
||||
/// <summary>Registers an advice handle with the registry.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="adviceKind">Type of advice to register.</param>
|
||||
public void RegisterAdviceHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
AdviceHandleKey key = new(serverHandle, itemHandle, adviceKind);
|
||||
adviceHandles[key] = new RegisteredAdviceHandle(
|
||||
serverHandle,
|
||||
itemHandle,
|
||||
adviceKind);
|
||||
}
|
||||
|
||||
/// <summary>Removes all advice handles for the specified server and item handles from the registry.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
public void RemoveAdviceHandles(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
foreach (AdviceHandleKey key in adviceHandles
|
||||
.Where(pair => pair.Value.ServerHandle == serverHandle && pair.Value.ItemHandle == itemHandle)
|
||||
.Select(pair => pair.Key)
|
||||
.ToArray())
|
||||
{
|
||||
adviceHandles.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Checks if the registry contains the specified advice handle.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="adviceKind">Type of advice to check.</param>
|
||||
public bool ContainsAdviceHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
return adviceHandles.ContainsKey(new AdviceHandleKey(serverHandle, itemHandle, adviceKind));
|
||||
}
|
||||
|
||||
private static long CreateItemKey(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return ((long)serverHandle << 32) | (uint)itemHandle;
|
||||
}
|
||||
|
||||
private readonly struct AdviceHandleKey : IEquatable<AdviceHandleKey>
|
||||
{
|
||||
private readonly int serverHandle;
|
||||
private readonly int itemHandle;
|
||||
private readonly MxAccessAdviceKind adviceKind;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="AdviceHandleKey"/> struct.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="adviceKind">Type of advice.</param>
|
||||
public AdviceHandleKey(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
this.serverHandle = serverHandle;
|
||||
this.itemHandle = itemHandle;
|
||||
this.adviceKind = adviceKind;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool Equals(AdviceHandleKey other)
|
||||
{
|
||||
return serverHandle == other.serverHandle
|
||||
&& itemHandle == other.itemHandle
|
||||
&& adviceKind == other.adviceKind;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override bool Equals(object? obj)
|
||||
{
|
||||
return obj is AdviceHandleKey other && Equals(other);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override int GetHashCode()
|
||||
{
|
||||
unchecked
|
||||
{
|
||||
int hashCode = serverHandle;
|
||||
hashCode = (hashCode * 397) ^ itemHandle;
|
||||
hashCode = (hashCode * 397) ^ (int)adviceKind;
|
||||
|
||||
return hashCode;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
using System;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Constants and metadata for MXAccess COM interop.
|
||||
/// </summary>
|
||||
public static class MxAccessInteropInfo
|
||||
{
|
||||
/// <summary>
|
||||
/// Versioned ProgID for the MXAccess COM server.
|
||||
/// </summary>
|
||||
public const string ProgId = "LMXProxy.LMXProxyServer.1";
|
||||
|
||||
/// <summary>
|
||||
/// Version-independent ProgID for the MXAccess COM server.
|
||||
/// </summary>
|
||||
public const string VersionIndependentProgId = "LMXProxy.LMXProxyServer";
|
||||
|
||||
/// <summary>
|
||||
/// Class ID (CLSID) of the MXAccess COM server.
|
||||
/// </summary>
|
||||
public const string Clsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the ArchestrA.MxAccess.dll interop assembly.
|
||||
/// </summary>
|
||||
public const string InteropAssemblyPath =
|
||||
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll";
|
||||
|
||||
/// <summary>
|
||||
/// Path to the installed MXAccess COM server DLL.
|
||||
/// </summary>
|
||||
public const string RegisteredServerPath =
|
||||
@"C:\Program Files (x86)\ArchestrA\Framework\Bin\LmxProxy.dll";
|
||||
|
||||
/// <summary>
|
||||
/// Full qualified name of the COM class.
|
||||
/// </summary>
|
||||
public const string ComClassName = "ArchestrA.MxAccess.LMXProxyServerClass";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the MXAccess interop assembly.
|
||||
/// </summary>
|
||||
public static string InteropAssemblyName =>
|
||||
typeof(LMXProxyServerClass).Assembly.GetName().Name ?? string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Version of the MXAccess interop assembly.
|
||||
/// </summary>
|
||||
public static Version InteropAssemblyVersion =>
|
||||
typeof(LMXProxyServerClass).Assembly.GetName().Version ?? new Version(0, 0);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Captures details about an MXAccess operation that failed during shutdown.
|
||||
/// </summary>
|
||||
public sealed class MxAccessShutdownFailure
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the shutdown failure record.
|
||||
/// </summary>
|
||||
/// <param name="operation">Name of the operation that failed.</param>
|
||||
/// <param name="serverHandle">Server handle if applicable.</param>
|
||||
/// <param name="itemHandle">Item handle if applicable.</param>
|
||||
/// <param name="exception">Exception that was raised.</param>
|
||||
public MxAccessShutdownFailure(
|
||||
string operation,
|
||||
int? serverHandle,
|
||||
int? itemHandle,
|
||||
Exception exception)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(operation))
|
||||
{
|
||||
throw new ArgumentException("Shutdown failure operation is required.", nameof(operation));
|
||||
}
|
||||
|
||||
Operation = operation;
|
||||
ServerHandle = serverHandle;
|
||||
ItemHandle = itemHandle;
|
||||
ExceptionType = exception?.GetType().FullName ?? string.Empty;
|
||||
HResult = exception?.HResult;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The operation that failed (e.g., Unregister, RemoveItem).
|
||||
/// </summary>
|
||||
public string Operation { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Server handle if applicable; otherwise null.
|
||||
/// </summary>
|
||||
public int? ServerHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Item handle if applicable; otherwise null.
|
||||
/// </summary>
|
||||
public int? ItemHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Full type name of the exception, or empty string if no exception.
|
||||
/// </summary>
|
||||
public string ExceptionType { get; }
|
||||
|
||||
/// <summary>
|
||||
/// HResult code if the exception has one; otherwise null.
|
||||
/// </summary>
|
||||
public int? HResult { get; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessShutdownResult
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="MxAccessShutdownResult"/> class.</summary>
|
||||
/// <param name="failures">List of failures encountered during graceful shutdown.</param>
|
||||
public MxAccessShutdownResult(IReadOnlyList<MxAccessShutdownFailure> failures)
|
||||
{
|
||||
Failures = failures ?? throw new ArgumentNullException(nameof(failures));
|
||||
}
|
||||
|
||||
/// <summary>Gets the list of shutdown failures.</summary>
|
||||
public IReadOnlyList<MxAccessShutdownFailure> Failures { get; }
|
||||
|
||||
/// <summary>Gets a value indicating whether the shutdown succeeded with no failures.</summary>
|
||||
public bool Succeeded => Failures.Count == 0;
|
||||
}
|
||||
@@ -0,0 +1,672 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
{
|
||||
private static readonly TimeSpan AlarmPollInterval = TimeSpan.FromMilliseconds(500);
|
||||
|
||||
private readonly IMxAccessComObjectFactory factory;
|
||||
private readonly IMxAccessEventSink eventSink;
|
||||
private readonly MxAccessEventQueue eventQueue;
|
||||
private readonly StaRuntime staRuntime;
|
||||
// Worker-024: the factory takes an Action so MxAccessStaSession can hand
|
||||
// the alarm handler its STA-affinity guard (a closure over
|
||||
// alarmConsumerThreadId captured at the factory call site). The handler
|
||||
// then invokes the guard at the entry of every method that touches the
|
||||
// wnwrap consumer, matching the STA-affinity invariant already enforced
|
||||
// for the poll path via EnsureOnAlarmConsumerThread.
|
||||
private readonly Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory;
|
||||
private StaCommandDispatcher? commandDispatcher;
|
||||
private MxAccessSession? session;
|
||||
private IAlarmCommandHandler? alarmCommandHandler;
|
||||
private CancellationTokenSource? alarmPollCts;
|
||||
private Task? alarmPollTask;
|
||||
private int? alarmConsumerThreadId;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default dependencies.
|
||||
/// </summary>
|
||||
public MxAccessStaSession()
|
||||
: this(
|
||||
new StaRuntime(),
|
||||
new MxAccessComObjectFactory(),
|
||||
new MxAccessEventQueue())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with default STA runtime,
|
||||
/// factory, and event queue, but with a custom alarm-command handler factory. The factory is
|
||||
/// invoked on the STA thread during
|
||||
/// <see cref="StartAsync(string, int, CancellationToken)"/>; pass <c>null</c> to opt out
|
||||
/// of alarm-side commands.
|
||||
/// </summary>
|
||||
internal MxAccessStaSession(Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory)
|
||||
: this(
|
||||
new StaRuntime(),
|
||||
new MxAccessComObjectFactory(),
|
||||
new MxAccessEventQueue(),
|
||||
alarmCommandHandlerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom STA runtime and factory.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="factory">MXAccess COM object factory.</param>
|
||||
/// <param name="eventSink">Event sink for MXAccess events.</param>
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
IMxAccessEventSink eventSink)
|
||||
: this(staRuntime, factory, eventSink, new MxAccessEventQueue())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom event queue.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="factory">MXAccess COM object factory.</param>
|
||||
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
MxAccessEventQueue eventQueue)
|
||||
: this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with custom event queue
|
||||
/// and an alarm-command handler factory.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="factory">MXAccess COM object factory.</param>
|
||||
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
|
||||
/// <param name="alarmCommandHandlerFactory">
|
||||
/// Factory that constructs the alarm-command handler from the event queue.
|
||||
/// Pass <c>null</c> to opt out of alarm-side commands.
|
||||
/// </param>
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
MxAccessEventQueue eventQueue,
|
||||
Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory)
|
||||
: this(staRuntime, factory, new MxAccessBaseEventSink(eventQueue), eventQueue, alarmCommandHandlerFactory)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all dependencies.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="factory">MXAccess COM object factory.</param>
|
||||
/// <param name="eventSink">Event sink for MXAccess events.</param>
|
||||
/// <param name="eventQueue">Event queue for buffering MXAccess events.</param>
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
IMxAccessEventSink eventSink,
|
||||
MxAccessEventQueue eventQueue)
|
||||
: this(staRuntime, factory, eventSink, eventQueue, alarmCommandHandlerFactory: null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="MxAccessStaSession"/> with all
|
||||
/// dependencies including an alarm-command handler factory. The factory is
|
||||
/// invoked on the STA thread during <see cref="StartAsync(string, int, CancellationToken)"/>;
|
||||
/// pass <c>null</c> to opt out of alarm-side commands (the worker rejects
|
||||
/// them with an "alarm consumer not configured" diagnostic).
|
||||
/// </summary>
|
||||
public MxAccessStaSession(
|
||||
StaRuntime staRuntime,
|
||||
IMxAccessComObjectFactory factory,
|
||||
IMxAccessEventSink eventSink,
|
||||
MxAccessEventQueue eventQueue,
|
||||
Func<MxAccessEventQueue, Action, IAlarmCommandHandler>? alarmCommandHandlerFactory)
|
||||
{
|
||||
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
|
||||
this.factory = factory ?? throw new ArgumentNullException(nameof(factory));
|
||||
this.eventSink = eventSink ?? throw new ArgumentNullException(nameof(eventSink));
|
||||
this.eventQueue = eventQueue ?? throw new ArgumentNullException(nameof(eventQueue));
|
||||
this.alarmCommandHandlerFactory = alarmCommandHandlerFactory;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the event queue for this session.
|
||||
/// </summary>
|
||||
public MxAccessEventQueue EventQueue => eventQueue;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MXAccess COM session asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="workerProcessId">Worker process identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready message.</returns>
|
||||
public Task<WorkerReady> StartAsync(
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return StartAsync(string.Empty, workerProcessId, cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the MXAccess COM session with a session ID asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="sessionId">Session identifier.</param>
|
||||
/// <param name="workerProcessId">Worker process identifier.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Worker ready message.</returns>
|
||||
public async Task<WorkerReady> StartAsync(
|
||||
string sessionId,
|
||||
int workerProcessId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
staRuntime.Start();
|
||||
|
||||
WorkerReady ready = await staRuntime.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
if (session is not null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess COM session has already been created.");
|
||||
}
|
||||
|
||||
session = MxAccessSession.Create(factory, eventSink, sessionId);
|
||||
if (alarmCommandHandlerFactory is not null)
|
||||
{
|
||||
// STA-affinity invariant: the alarm consumer factory and
|
||||
// every IMxAccessAlarmConsumer call must run on the STA
|
||||
// thread, because the production wnwrap consumer holds an
|
||||
// Apartment-threaded COM object. The factory runs here
|
||||
// inside staRuntime.InvokeAsync, so this records the STA
|
||||
// thread id; RunAlarmPollLoopAsync then asserts each
|
||||
// PollOnce executes on the same thread.
|
||||
alarmConsumerThreadId = Environment.CurrentManagedThreadId;
|
||||
// Worker-024: hand the handler an affinity guard so each
|
||||
// of its command-path entries (Subscribe / Acknowledge /
|
||||
// AcknowledgeByName / QueryActive / Unsubscribe / PollOnce)
|
||||
// asserts the same STA-affinity invariant the poll path
|
||||
// already enforced. Without this the command path relied
|
||||
// on convention alone; a future refactor that let a
|
||||
// command run off-STA would silently deadlock on
|
||||
// cross-apartment marshaling against the wnwrap consumer.
|
||||
alarmCommandHandler = alarmCommandHandlerFactory(
|
||||
eventQueue,
|
||||
EnsureOnAlarmConsumerThread);
|
||||
}
|
||||
commandDispatcher = new StaCommandDispatcher(
|
||||
staRuntime,
|
||||
new MxAccessCommandExecutor(
|
||||
session,
|
||||
new VariantConverter(),
|
||||
alarmCommandHandler,
|
||||
// ReadBulk needs to pump Windows messages while it waits
|
||||
// for the first OnDataChange callback so the inbound COM
|
||||
// event can dispatch on this same STA thread. The pump
|
||||
// step closes over staRuntime so it always pumps the
|
||||
// pump tied to the apartment that owns this session.
|
||||
pumpStep: () => staRuntime.PumpPendingMessages()));
|
||||
|
||||
return session.CreateWorkerReady(workerProcessId);
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
if (alarmCommandHandler is not null)
|
||||
{
|
||||
alarmPollCts = new CancellationTokenSource();
|
||||
alarmPollTask = RunAlarmPollLoopAsync(alarmCommandHandler, alarmPollCts.Token);
|
||||
}
|
||||
|
||||
return ready;
|
||||
}
|
||||
|
||||
private Task RunAlarmPollLoopAsync(
|
||||
IAlarmCommandHandler handler,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(async () =>
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await Task.Delay(AlarmPollInterval, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await staRuntime.InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
EnsureOnAlarmConsumerThread();
|
||||
handler.PollOnce();
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// STA runtime or alarm handler disposed — stop the loop gracefully.
|
||||
return;
|
||||
}
|
||||
catch (StaRuntimeShutdownException)
|
||||
{
|
||||
// STA runtime shutting down — stop the loop gracefully.
|
||||
// The dedicated shutdown type lets us distinguish this
|
||||
// graceful-stop signal from the STA-affinity assertion
|
||||
// raised by EnsureOnAlarmConsumerThread (Worker-008),
|
||||
// which is also an InvalidOperationException but signals
|
||||
// a programming-error regression — that case falls through
|
||||
// to the generic Exception arm below and is recorded as a
|
||||
// fault on the event queue, so an affinity regression
|
||||
// becomes observable on the IPC fault path instead of
|
||||
// silently stopping alarm delivery.
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A real alarm-poll failure (COMException from
|
||||
// GetXmlCurrentAlarms2, malformed-XML parse failure, an
|
||||
// STA-affinity InvalidOperationException from
|
||||
// EnsureOnAlarmConsumerThread, etc.). Record it as a
|
||||
// fault on the event queue so a broken alarm subscription
|
||||
// — or an affinity-invariant regression — becomes
|
||||
// observable on the IPC fault path instead of silently
|
||||
// faulting this never-awaited task. The loop then stops —
|
||||
// the subscription is dead.
|
||||
eventQueue.RecordFault(CreateAlarmPollFault(exception));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void EnsureOnAlarmConsumerThread()
|
||||
{
|
||||
AssertOnAlarmConsumerThread(alarmConsumerThreadId, Environment.CurrentManagedThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the STA-affinity invariant for the alarm consumer: every
|
||||
/// <see cref="IMxAccessAlarmConsumer"/> call (and the consumer factory)
|
||||
/// must run on the same thread the consumer was created on (the worker's
|
||||
/// STA). Throws <see cref="InvalidOperationException"/> when a caller
|
||||
/// breaks affinity — a programming error that would otherwise risk a
|
||||
/// cross-apartment COM deadlock in the production wnwrap consumer, since
|
||||
/// its CLSID is registered <c>ThreadingModel=Apartment</c>. The check is
|
||||
/// a no-op until the consumer thread has been recorded (no alarm handler
|
||||
/// configured, or session not yet started).
|
||||
/// </summary>
|
||||
/// <param name="expectedThreadId">
|
||||
/// The managed thread id the alarm consumer was created on, or
|
||||
/// <c>null</c> if no alarm consumer is configured.
|
||||
/// </param>
|
||||
/// <param name="actualThreadId">The current managed thread id.</param>
|
||||
internal static void AssertOnAlarmConsumerThread(int? expectedThreadId, int actualThreadId)
|
||||
{
|
||||
if (expectedThreadId is not null && actualThreadId != expectedThreadId.Value)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Alarm consumer accessed off its owning STA thread. Expected thread {expectedThreadId.Value}, "
|
||||
+ $"actual {actualThreadId}. All IMxAccessAlarmConsumer calls must run on the STA that "
|
||||
+ "created the consumer.");
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkerFault CreateAlarmPollFault(Exception exception)
|
||||
{
|
||||
string message =
|
||||
$"MXAccess alarm poll failed: {exception.Message}";
|
||||
WorkerFault fault = new()
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
ExceptionType = exception.GetType().FullName ?? string.Empty,
|
||||
DiagnosticMessage = message,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||
Message = message,
|
||||
},
|
||||
};
|
||||
|
||||
if (exception is System.Runtime.InteropServices.COMException comException)
|
||||
{
|
||||
fault.Hresult = comException.HResult;
|
||||
}
|
||||
|
||||
return fault;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a command to the STA thread for execution asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to dispatch.</param>
|
||||
/// <returns>Command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
if (commandDispatcher is null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||
}
|
||||
|
||||
return commandDispatcher.DispatchAsync(command);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Captures a heartbeat snapshot of the session's runtime state.
|
||||
/// </summary>
|
||||
/// <returns>Heartbeat snapshot.</returns>
|
||||
public WorkerRuntimeHeartbeatSnapshot CaptureHeartbeat()
|
||||
{
|
||||
uint pendingCommandCount = 0;
|
||||
string currentCommandCorrelationId = string.Empty;
|
||||
|
||||
if (commandDispatcher is not null)
|
||||
{
|
||||
pendingCommandCount = (uint)commandDispatcher.PendingCommandCount;
|
||||
currentCommandCorrelationId = commandDispatcher.CurrentCommandCorrelationId;
|
||||
}
|
||||
|
||||
return new WorkerRuntimeHeartbeatSnapshot(
|
||||
staRuntime.LastActivityUtc,
|
||||
pendingCommandCount,
|
||||
(uint)eventQueue.Count,
|
||||
eventQueue.LastEventSequence,
|
||||
currentCommandCorrelationId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests graceful shutdown of the command dispatcher.
|
||||
/// </summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
commandDispatcher?.RequestShutdown();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains up to the specified number of events from the queue.
|
||||
/// </summary>
|
||||
/// <param name="maxEvents">Maximum events to drain.</param>
|
||||
/// <returns>Drained events.</returns>
|
||||
public IReadOnlyList<WorkerEvent> DrainEvents(uint maxEvents)
|
||||
{
|
||||
return eventQueue.Drain(maxEvents);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Drains a fault from the queue if present.
|
||||
/// </summary>
|
||||
/// <returns>Drained fault or null.</returns>
|
||||
public WorkerFault? DrainFault()
|
||||
{
|
||||
return eventQueue.DrainFault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a queued command by correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Correlation ID of the command to cancel.</param>
|
||||
/// <returns>True if cancelled; otherwise false.</returns>
|
||||
public bool CancelCommand(string correlationId)
|
||||
{
|
||||
return commandDispatcher?.CancelQueuedCommand(correlationId) ?? false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registered server handles asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Registered server handles.</returns>
|
||||
public Task<IReadOnlyList<RegisteredServerHandle>> GetRegisteredServerHandlesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||
}
|
||||
|
||||
return staRuntime.InvokeAsync(
|
||||
() => session.HandleRegistry.ServerHandles,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registered item handles asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Registered item handles.</returns>
|
||||
public Task<IReadOnlyList<RegisteredItemHandle>> GetRegisteredItemHandlesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||
}
|
||||
|
||||
return staRuntime.InvokeAsync(
|
||||
() => session.HandleRegistry.ItemHandles,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the registered advice handles asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Registered advice handles.</returns>
|
||||
public Task<IReadOnlyList<RegisteredAdviceHandle>> GetRegisteredAdviceHandlesAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is null)
|
||||
{
|
||||
throw new InvalidOperationException("MXAccess COM session has not been started.");
|
||||
}
|
||||
|
||||
return staRuntime.InvokeAsync(
|
||||
() => session.HandleRegistry.AdviceHandles,
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Performs graceful shutdown of the MXAccess session within a timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time allowed for shutdown.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Shutdown result with any cleanup failures.</returns>
|
||||
public async Task<MxAccessShutdownResult> ShutdownGracefullyAsync(
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(timeout),
|
||||
"MXAccess graceful shutdown timeout must be greater than zero.");
|
||||
}
|
||||
|
||||
if (disposed)
|
||||
{
|
||||
return new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
|
||||
}
|
||||
|
||||
commandDispatcher?.RequestShutdown();
|
||||
|
||||
// Cancel the STA poll loop before disposing the alarm handler.
|
||||
// The loop references the alarm handler and must be stopped first
|
||||
// so that no further PollOnce calls race with disposal.
|
||||
CancellationTokenSource? pollCtsToDispose = alarmPollCts;
|
||||
Task? pollTaskToAwait = alarmPollTask;
|
||||
alarmPollCts = null;
|
||||
alarmPollTask = null;
|
||||
if (pollCtsToDispose is not null)
|
||||
{
|
||||
pollCtsToDispose.Cancel();
|
||||
if (pollTaskToAwait is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await pollTaskToAwait.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — poll loop cancellation must not block data shutdown.
|
||||
}
|
||||
}
|
||||
pollCtsToDispose.Dispose();
|
||||
}
|
||||
|
||||
// Stop the alarm consumer's polling timer and tear down the
|
||||
// dispatcher BEFORE the data-side cleanup begins. The alarm
|
||||
// consumer holds a wnwrap COM RCW that needs the STA pump to
|
||||
// unwind cleanly; doing it here gives it the opportunity while
|
||||
// the STA is still alive.
|
||||
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
|
||||
alarmCommandHandler = null;
|
||||
if (alarmHandlerToDispose is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await staRuntime.InvokeAsync(
|
||||
() => alarmHandlerToDispose.Dispose(),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Swallow — alarm cleanup must not block data shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
Stopwatch stopwatch = Stopwatch.StartNew();
|
||||
MxAccessShutdownResult result;
|
||||
if (session is null)
|
||||
{
|
||||
result = new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>());
|
||||
}
|
||||
else
|
||||
{
|
||||
using CancellationTokenSource shutdownCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
shutdownCancellation.CancelAfter(timeout);
|
||||
|
||||
Task<MxAccessShutdownResult> cleanupTask = staRuntime.InvokeAsync(
|
||||
() => session.ShutdownGracefully(),
|
||||
shutdownCancellation.Token);
|
||||
Task delayTask = Task.Delay(timeout, cancellationToken);
|
||||
Task completedTask = await Task.WhenAny(cleanupTask, delayTask).ConfigureAwait(false);
|
||||
if (completedTask != cleanupTask)
|
||||
{
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
result = await cleanupTask.ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
|
||||
}
|
||||
}
|
||||
|
||||
TimeSpan remaining = timeout - stopwatch.Elapsed;
|
||||
if (remaining <= TimeSpan.Zero || !staRuntime.Shutdown(remaining))
|
||||
{
|
||||
throw new TimeoutException($"MXAccess graceful shutdown exceeded {timeout}.");
|
||||
}
|
||||
|
||||
staRuntime.Dispose();
|
||||
disposed = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>Releases resources and shuts down the session.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
RequestShutdown();
|
||||
|
||||
// Cancel the STA poll loop and join it before disposing the alarm
|
||||
// handler. Joining (rather than discarding alarmPollTask) makes the
|
||||
// stop deterministic: once Dispose returns, no further PollOnce calls
|
||||
// can be in flight, so callers and tests can rely on a frozen poll
|
||||
// count instead of an elapsed-time "no further polls" window.
|
||||
CancellationTokenSource? pollCtsToDispose = alarmPollCts;
|
||||
Task? pollTaskToJoin = alarmPollTask;
|
||||
alarmPollCts = null;
|
||||
alarmPollTask = null;
|
||||
if (pollCtsToDispose is not null)
|
||||
{
|
||||
try { pollCtsToDispose.Cancel(); } catch { }
|
||||
if (pollTaskToJoin is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
pollTaskToJoin.Wait(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
catch (AggregateException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
try { pollCtsToDispose.Dispose(); } catch { }
|
||||
}
|
||||
|
||||
IAlarmCommandHandler? alarmHandlerToDispose = alarmCommandHandler;
|
||||
alarmCommandHandler = null;
|
||||
if (alarmHandlerToDispose is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
staRuntime.InvokeAsync(() => alarmHandlerToDispose.Dispose())
|
||||
.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (AggregateException) { }
|
||||
catch (ObjectDisposedException) { }
|
||||
}
|
||||
|
||||
if (session is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
staRuntime.InvokeAsync(() => session.Dispose())
|
||||
.Wait(TimeSpan.FromSeconds(2));
|
||||
}
|
||||
catch (AggregateException)
|
||||
{
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
staRuntime.Dispose();
|
||||
disposed = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading;
|
||||
using Google.Protobuf.Collections;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Per-session cache of the most recent <c>OnDataChange</c> payload for
|
||||
/// each (server handle, item handle) pair. Written by the MXAccess event
|
||||
/// sink as new OnDataChange callbacks arrive; read by the ReadBulk command
|
||||
/// executor so it can satisfy a "current value" request from a tag that is
|
||||
/// already advised without modifying the existing subscription.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Both writers and readers run on the worker's STA thread (COM dispatches
|
||||
/// events on the apartment thread; commands also execute on the STA), so
|
||||
/// no internal locking is required. The class is still nominally
|
||||
/// thread-safe via a single sync root in case tests drive it from a
|
||||
/// non-STA thread.
|
||||
/// </remarks>
|
||||
public sealed class MxAccessValueCache
|
||||
{
|
||||
private readonly Dictionary<long, CachedValue> entries = new();
|
||||
private readonly object syncRoot = new();
|
||||
|
||||
/// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <param name="mxEvent">The protobuf MxEvent created by the event mapper.</param>
|
||||
public void Set(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxEvent mxEvent)
|
||||
{
|
||||
if (mxEvent is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(mxEvent));
|
||||
}
|
||||
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
lock (syncRoot)
|
||||
{
|
||||
ulong nextVersion = entries.TryGetValue(key, out CachedValue existing)
|
||||
? existing.Version + 1
|
||||
: 1UL;
|
||||
|
||||
entries[key] = new CachedValue(
|
||||
nextVersion,
|
||||
mxEvent.Value,
|
||||
mxEvent.Quality,
|
||||
mxEvent.SourceTimestamp,
|
||||
mxEvent.Statuses);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Tries to read the most recent cached value for the handle pair.</summary>
|
||||
public bool TryGet(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
out CachedValue value)
|
||||
{
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
lock (syncRoot)
|
||||
{
|
||||
return entries.TryGetValue(key, out value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the cache slot for a handle pair. The session calls this
|
||||
/// when an item is unregistered so stale values are not served to a
|
||||
/// subsequent ReadBulk after a tag is removed and re-added.
|
||||
/// </summary>
|
||||
public void Remove(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
long key = CreateItemKey(serverHandle, itemHandle);
|
||||
lock (syncRoot)
|
||||
{
|
||||
entries.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Waits until the cache entry's version exceeds <paramref name="sinceVersion"/>
|
||||
/// or the deadline elapses, calling <paramref name="pumpStep"/> on every poll
|
||||
/// iteration so the worker's STA can dispatch the inbound MXAccess message.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="itemHandle">MXAccess item handle.</param>
|
||||
/// <param name="sinceVersion">Version snapshot captured before the wait.</param>
|
||||
/// <param name="deadlineUtc">Absolute UTC deadline.</param>
|
||||
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
|
||||
/// <param name="pollIntervalMs">How long to sleep between pump cycles. Default 5 ms.</param>
|
||||
public bool TryWaitForUpdate(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
ulong sinceVersion,
|
||||
DateTime deadlineUtc,
|
||||
Action pumpStep,
|
||||
out CachedValue value,
|
||||
int pollIntervalMs = 5)
|
||||
{
|
||||
if (pumpStep is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(pumpStep));
|
||||
}
|
||||
|
||||
while (true)
|
||||
{
|
||||
pumpStep();
|
||||
|
||||
if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (DateTime.UtcNow >= deadlineUtc)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
Thread.Sleep(pollIntervalMs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Returns the current version for a handle pair, or 0 if no entry exists.</summary>
|
||||
public ulong CurrentVersion(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return TryGet(serverHandle, itemHandle, out CachedValue existing)
|
||||
? existing.Version
|
||||
: 0UL;
|
||||
}
|
||||
|
||||
private static long CreateItemKey(
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
return ((long)serverHandle << 32) | (uint)itemHandle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the most recent OnDataChange payload for a handle pair.
|
||||
/// <see cref="Version"/> increments by one on every <see cref="Set"/>
|
||||
/// call so the bulk read executor can detect "a new value arrived
|
||||
/// since I started waiting".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Plain readonly struct (not a record) so this compiles under the
|
||||
/// worker's net48 target, which lacks <c>IsExternalInit</c>.
|
||||
/// </remarks>
|
||||
public readonly struct CachedValue
|
||||
{
|
||||
/// <summary>Initializes a new cached value snapshot.</summary>
|
||||
public CachedValue(
|
||||
ulong version,
|
||||
MxValue value,
|
||||
int quality,
|
||||
Timestamp sourceTimestamp,
|
||||
RepeatedField<MxStatusProxy> statuses)
|
||||
{
|
||||
Version = version;
|
||||
Value = value;
|
||||
Quality = quality;
|
||||
SourceTimestamp = sourceTimestamp;
|
||||
Statuses = statuses;
|
||||
}
|
||||
|
||||
/// <summary>Monotonic per-handle version counter.</summary>
|
||||
public ulong Version { get; }
|
||||
|
||||
/// <summary>The cached MxValue payload.</summary>
|
||||
public MxValue Value { get; }
|
||||
|
||||
/// <summary>Quality code from the OnDataChange event.</summary>
|
||||
public int Quality { get; }
|
||||
|
||||
/// <summary>Source timestamp from the OnDataChange event.</summary>
|
||||
public Timestamp SourceTimestamp { get; }
|
||||
|
||||
/// <summary>MxStatusProxy entries from the OnDataChange event.</summary>
|
||||
public RepeatedField<MxStatusProxy> Statuses { get; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Single alarm record as emitted by the wnwrapConsumer XML stream.
|
||||
/// Field names match the captured XML schema (see
|
||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" section).
|
||||
/// </summary>
|
||||
public sealed class MxAlarmSnapshotRecord
|
||||
{
|
||||
public Guid AlarmGuid { get; set; }
|
||||
public DateTime TransitionTimestampUtc { get; set; }
|
||||
public string ProviderNode { get; set; } = string.Empty;
|
||||
public string ProviderName { get; set; } = string.Empty;
|
||||
public string Group { get; set; } = string.Empty;
|
||||
public string TagName { get; set; } = string.Empty;
|
||||
public string Type { get; set; } = string.Empty;
|
||||
public string Value { get; set; } = string.Empty;
|
||||
public string Limit { get; set; } = string.Empty;
|
||||
public int Priority { get; set; }
|
||||
public MxAlarmStateKind State { get; set; }
|
||||
public string OperatorNode { get; set; } = string.Empty;
|
||||
public string OperatorName { get; set; } = string.Empty;
|
||||
public string AlarmComment { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Library-agnostic alarm-state enum. Mirrors the four <c>STATE</c>
|
||||
/// values returned by AVEVA's <c>WNWRAPCONSUMERLib</c> XML payload —
|
||||
/// <c>UNACK_ALM</c>, <c>ACK_ALM</c>, <c>UNACK_RTN</c>, <c>ACK_RTN</c>.
|
||||
/// Decoupling the consumer from any specific COM library keeps the
|
||||
/// proto-build path testable without an AVEVA install.
|
||||
/// </summary>
|
||||
public enum MxAlarmStateKind
|
||||
{
|
||||
Unspecified = 0,
|
||||
UnackAlm = 1,
|
||||
AckAlm = 2,
|
||||
UnackRtn = 3,
|
||||
AckRtn = 4,
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// One transition emitted by the consumer's snapshot diff. Pairs the
|
||||
/// latest record with its previous state so the proto layer can decide
|
||||
/// whether the transition is a Raise / Acknowledge / Clear.
|
||||
/// </summary>
|
||||
public sealed class MxAlarmTransitionEvent : EventArgs
|
||||
{
|
||||
public MxAlarmSnapshotRecord Record { get; set; } = new MxAlarmSnapshotRecord();
|
||||
|
||||
/// <summary>
|
||||
/// The state on the consumer's previous polled snapshot, or
|
||||
/// <see cref="MxAlarmStateKind.Unspecified"/> when this is the
|
||||
/// first time the GUID has been observed.
|
||||
/// </summary>
|
||||
public MxAlarmStateKind PreviousState { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class RegisteredAdviceHandle
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="RegisteredAdviceHandle"/> class.</summary>
|
||||
/// <param name="serverHandle">Handle returned by the worker.</param>
|
||||
/// <param name="itemHandle">Handle returned by the worker.</param>
|
||||
/// <param name="adviceKind">Type of advice.</param>
|
||||
public RegisteredAdviceHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
MxAccessAdviceKind adviceKind)
|
||||
{
|
||||
ServerHandle = serverHandle;
|
||||
ItemHandle = itemHandle;
|
||||
AdviceKind = adviceKind;
|
||||
}
|
||||
|
||||
/// <summary>Gets the server handle.</summary>
|
||||
public int ServerHandle { get; }
|
||||
|
||||
/// <summary>Gets the item handle.</summary>
|
||||
public int ItemHandle { get; }
|
||||
|
||||
/// <summary>Gets the advice kind.</summary>
|
||||
public MxAccessAdviceKind AdviceKind { get; }
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Metadata for an item handle registered in an MXAccess session.
|
||||
/// </summary>
|
||||
public sealed class RegisteredItemHandle
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a registered item handle with complete metadata.
|
||||
/// </summary>
|
||||
/// <param name="serverHandle">Handle returned by Register.</param>
|
||||
/// <param name="itemHandle">Handle returned by AddItem.</param>
|
||||
/// <param name="itemDefinition">Item definition (tag address).</param>
|
||||
/// <param name="itemContext">Item context string.</param>
|
||||
/// <param name="hasItemContext">Whether this item has an associated context.</param>
|
||||
public RegisteredItemHandle(
|
||||
int serverHandle,
|
||||
int itemHandle,
|
||||
string itemDefinition,
|
||||
string itemContext,
|
||||
bool hasItemContext)
|
||||
{
|
||||
ServerHandle = serverHandle;
|
||||
ItemHandle = itemHandle;
|
||||
ItemDefinition = itemDefinition;
|
||||
ItemContext = itemContext;
|
||||
HasItemContext = hasItemContext;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the server handle that owns this item.
|
||||
/// </summary>
|
||||
public int ServerHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item handle within the server.
|
||||
/// </summary>
|
||||
public int ItemHandle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item definition (tag address).
|
||||
/// </summary>
|
||||
public string ItemDefinition { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the item context.
|
||||
/// </summary>
|
||||
public string ItemContext { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether this item has an associated context.
|
||||
/// </summary>
|
||||
public bool HasItemContext { get; }
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class RegisteredServerHandle
|
||||
{
|
||||
/// <summary>Initializes a new registered server handle.</summary>
|
||||
/// <param name="serverHandle">MXAccess server handle.</param>
|
||||
/// <param name="clientName">Client name associated with the handle.</param>
|
||||
public RegisteredServerHandle(
|
||||
int serverHandle,
|
||||
string clientName)
|
||||
{
|
||||
ServerHandle = serverHandle;
|
||||
ClientName = clientName;
|
||||
}
|
||||
|
||||
/// <summary>The MXAccess server handle.</summary>
|
||||
public int ServerHandle { get; }
|
||||
|
||||
/// <summary>The client name associated with this server handle.</summary>
|
||||
public string ClientName { get; }
|
||||
}
|
||||
@@ -0,0 +1,569 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Xml;
|
||||
using WNWRAPCONSUMERLib;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Production <see cref="IMxAccessAlarmConsumer"/> backed by AVEVA's
|
||||
/// standalone <c>WNWRAPCONSUMERLib.wwAlarmConsumerClass</c> COM object
|
||||
/// (CLSID <c>{7AB52E5F-36B2-4A30-AE46-952A746F667C}</c>, hosted by
|
||||
/// <c>C:\Program Files (x86)\Common Files\ArchestrA\wnwrapConsumer.dll</c>).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Replaces the earlier <c>AlarmClientConsumer</c> built on
|
||||
/// <c>aaAlarmManagedClient.AlarmClient</c>, which crashed in
|
||||
/// <c>GetHighPriAlarm</c> with <c>ArgumentOutOfRangeException</c>
|
||||
/// (FILETIME→DateTime auto-marshaling on AVEVA's sentinel timestamps).
|
||||
/// The wnwrap surface returns the alarm record as a BSTR XML string
|
||||
/// via <c>GetXmlCurrentAlarms2</c>; timestamps arrive as ASCII
|
||||
/// <c>DATE</c> + <c>TIME</c> + <c>GMTOFFSET</c> + <c>DSTADJUST</c>
|
||||
/// fields and never touch the .NET DateTime marshaler. See
|
||||
/// <c>docs/AlarmClientDiscovery.md</c> "Option A — captured" for
|
||||
/// the discovery and the captured payload schema.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <strong>Threading.</strong> The wnwrap CLSID is registered with
|
||||
/// <c>ThreadingModel=Apartment</c>. The consumer must be created
|
||||
/// and operated from an STA thread; the worker's
|
||||
/// <see cref="MxAccessStaSession"/> runs an STA pump that hosts it.
|
||||
/// The consumer owns <em>no</em> internal timer: every COM call
|
||||
/// (<c>Subscribe</c>, <c>PollOnce</c>, <c>AcknowledgeBy*</c>) must
|
||||
/// be invoked on the STA that created the consumer. Polling cadence
|
||||
/// is driven externally by the worker's STA via
|
||||
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c>, which
|
||||
/// keeps every <c>GetXmlCurrentAlarms2</c> call on the apartment that
|
||||
/// owns the COM object. A thread-pool timer would call the COM API
|
||||
/// off the owning STA and can deadlock on cross-apartment marshaling
|
||||
/// when the STA is not pumping messages, so no such timer exists.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
{
|
||||
private const string DefaultProductName = "OtOpcUa.MxGateway";
|
||||
private const string DefaultApplicationName = "OtOpcUa.ZB.MOM.WW.MxGateway.Worker";
|
||||
private const string DefaultVersion = "1.0";
|
||||
private const int DefaultMaxAlarmsPerFetch = 1024;
|
||||
|
||||
private readonly object syncRoot = new object();
|
||||
private readonly Dictionary<Guid, MxAlarmSnapshotRecord> latestSnapshot =
|
||||
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||
private readonly int maxAlarmsPerFetch;
|
||||
|
||||
private wwAlarmConsumerClass? client;
|
||||
private wwAlarmConsumerClass? ackClient;
|
||||
private bool subscribed;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
/// Production constructor — creates the wnwrap COM object on the
|
||||
/// current thread (which must be the worker's STA). Polling is driven
|
||||
/// externally by the STA via
|
||||
/// <c>StaRuntime.InvokeAsync(() => consumer.PollOnce())</c> so that
|
||||
/// every COM call stays on the STA that owns the apartment.
|
||||
/// </summary>
|
||||
public WnWrapAlarmConsumer()
|
||||
: this(new wwAlarmConsumerClass(), DefaultMaxAlarmsPerFetch)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test seam / explicit construction.
|
||||
/// </summary>
|
||||
public WnWrapAlarmConsumer(
|
||||
wwAlarmConsumerClass client,
|
||||
int maxAlarmsPerFetch)
|
||||
{
|
||||
this.client = client ?? throw new ArgumentNullException(nameof(client));
|
||||
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
|
||||
? maxAlarmsPerFetch
|
||||
: DefaultMaxAlarmsPerFetch;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(string subscription)
|
||||
{
|
||||
if (subscription is null) throw new ArgumentNullException(nameof(subscription));
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (subscribed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"WnWrapAlarmConsumer.Subscribe was called more than once; " +
|
||||
"wwAlarmConsumerClass.Subscribe replaces the previous filter and is not idempotent.");
|
||||
}
|
||||
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
// Use the IwwAlarmConsumer (v1) prefix-named methods for the
|
||||
// lifecycle. Empirically (live dev-rig 2026-05-01) this is the
|
||||
// only path that lets AlarmAckByName succeed afterwards. The
|
||||
// v2 Initialize/Register/Subscribe methods on the class
|
||||
// succeed (return 0) but acks against that consumer state
|
||||
// return -55. The v1 prefix path is what WIN-911-style code
|
||||
// uses against the same wnwrap library.
|
||||
int init = com.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName);
|
||||
if (init != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.InitializeConsumer returned non-zero status {init}.");
|
||||
}
|
||||
|
||||
// hWnd=0: wnwrap supports a pull-based model — no message pump
|
||||
// is required. GetXmlCurrentAlarms2 is polled by the worker's STA
|
||||
// via StaRuntime.InvokeAsync(() => consumer.PollOnce()); this type
|
||||
// owns no internal timer.
|
||||
int reg = com.IwwAlarmConsumer_RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: DefaultProductName,
|
||||
szApplicationName: DefaultApplicationName,
|
||||
szVersion: DefaultVersion);
|
||||
if (reg != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.RegisterConsumer returned non-zero status {reg}.");
|
||||
}
|
||||
|
||||
int sub = com.IwwAlarmConsumer_Subscribe(
|
||||
szSubscription: subscription,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
if (sub != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.Subscribe('{subscription}') returned non-zero status {sub}.");
|
||||
}
|
||||
|
||||
// Empirically required: even though the round-trip echo of
|
||||
// SetXmlAlarmQuery is mangled (see docs/AlarmClientDiscovery.md),
|
||||
// calling it is necessary for subsequent GetXmlCurrentAlarms2
|
||||
// calls to succeed. Without it, GetXmlCurrentAlarms2 returns
|
||||
// E_FAIL (HRESULT 0x80004005) on the first poll. SetXmlAlarmQuery
|
||||
// also breaks AlarmAckByName on the same consumer (rejects with
|
||||
// -55), so a separate ack-only consumer is provisioned below
|
||||
// that gets only Initialize/Register/Subscribe (no SetXmlAlarmQuery).
|
||||
//
|
||||
// The wnwrap interop signature is `void SetXmlAlarmQuery(string)`
|
||||
// — there is no integer return code to gate on like the other v1
|
||||
// lifecycle calls in this method. A genuine failure surfaces as a
|
||||
// COM exception (mapped from the underlying HRESULT). Wrap the
|
||||
// call so a failure becomes an InvalidOperationException with
|
||||
// diagnostic context, matching the other call-gates' failure
|
||||
// shape rather than letting an opaque COMException escape with
|
||||
// no indication that the alarm subscription is now misconfigured
|
||||
// and the next GetXmlCurrentAlarms2 poll will fail with E_FAIL.
|
||||
string xmlQuery = ComposeXmlAlarmQuery(subscription);
|
||||
try
|
||||
{
|
||||
com.SetXmlAlarmQuery(xmlQuery);
|
||||
}
|
||||
catch (COMException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"wwAlarmConsumer.SetXmlAlarmQuery failed with HRESULT 0x{ex.HResult:X8}; " +
|
||||
"subsequent GetXmlCurrentAlarms2 polls would return E_FAIL.",
|
||||
ex);
|
||||
}
|
||||
|
||||
// Provision a parallel COM consumer for ack calls. It runs the
|
||||
// v1 lifecycle (Initialize/Register/Subscribe) only; without
|
||||
// SetXmlAlarmQuery, AlarmAckByName succeeds. State is read-only
|
||||
// — we never poll this consumer.
|
||||
ackClient = new wwAlarmConsumerClass();
|
||||
int ackInit = ackClient.IwwAlarmConsumer_InitializeConsumer(DefaultApplicationName + ".ack");
|
||||
int ackReg = ackClient.IwwAlarmConsumer_RegisterConsumer(
|
||||
hWnd: 0,
|
||||
szProductName: DefaultProductName,
|
||||
szApplicationName: DefaultApplicationName + ".ack",
|
||||
szVersion: DefaultVersion);
|
||||
int ackSub = ackClient.IwwAlarmConsumer_Subscribe(
|
||||
szSubscription: subscription,
|
||||
wFromPri: 1,
|
||||
wToPri: 999,
|
||||
QueryType: eQueryType.qtSummary,
|
||||
SortFlags: eSortFlags.sfReturnNewestFirst,
|
||||
FilterMask: eAlarmFilterState.asAlarmActiveNow,
|
||||
FilterSpecification: eAlarmFilterState.asAlarmActiveNow);
|
||||
if (ackInit != 0 || ackReg != 0 || ackSub != 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Ack consumer setup returned non-zero status: " +
|
||||
$"Initialize={ackInit}, Register={ackReg}, Subscribe={ackSub}.");
|
||||
}
|
||||
|
||||
subscribed = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByGuid(
|
||||
Guid alarmGuid,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
wwAlarmConsumerClass com = client
|
||||
?? throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
// VBGUID is wnwrap's GUID interop struct (same memory layout as
|
||||
// System.Guid: int32 + 2x int16 + 8x byte). Convert via a single
|
||||
// unmanaged-blittable round-trip.
|
||||
VBGUID vb = ToVbGuid(alarmGuid);
|
||||
return com.AlarmAckByGUID(
|
||||
AlmGUID: vb,
|
||||
szComment: ackComment ?? string.Empty,
|
||||
szOprName: ackOperatorName ?? string.Empty,
|
||||
szNode: ackOperatorNode ?? string.Empty,
|
||||
szDomainName: ackOperatorDomain ?? string.Empty,
|
||||
szOprFullName: ackOperatorFullName ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public int AcknowledgeByName(
|
||||
string alarmName,
|
||||
string providerName,
|
||||
string groupName,
|
||||
string ackComment,
|
||||
string ackOperatorName,
|
||||
string ackOperatorNode,
|
||||
string ackOperatorDomain,
|
||||
string ackOperatorFullName)
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
|
||||
// Use the parallel ack-only consumer (no SetXmlAlarmQuery applied)
|
||||
// — see docs/AlarmClientDiscovery.md "Option A — captured" for the
|
||||
// empirical justification.
|
||||
wwAlarmConsumerClass com = ackClient
|
||||
?? throw new InvalidOperationException(
|
||||
"Cannot acknowledge: WnWrapAlarmConsumer was disposed or has not been subscribed yet.");
|
||||
|
||||
// Empirically (live dev-rig 2026-05-01): the IwwAlarmConsumer2
|
||||
// 8-arg AlarmAckByName returns -55 on this AVEVA build (looks like
|
||||
// a stub). The legacy 6-arg IwwAlarmConsumer.AlarmAckByName works
|
||||
// and reaches the alarm-history path correctly. Operator-domain
|
||||
// and operator-full-name fields are accepted by the proto contract
|
||||
// for forward-compat but are not propagated to AVEVA today —
|
||||
// wrapped in the 6-arg call so domain/full-name go to the
|
||||
// alarm-history operator-name field via the szOprName parameter.
|
||||
// Suppress unused-warning explicitly:
|
||||
_ = ackOperatorDomain;
|
||||
_ = ackOperatorFullName;
|
||||
return com.AlarmAckByName(
|
||||
szAlarmName: alarmName ?? string.Empty,
|
||||
szProviderName: providerName ?? string.Empty,
|
||||
szGroupName: groupName ?? string.Empty,
|
||||
szComment: ackComment ?? string.Empty,
|
||||
szOprName: ackOperatorName ?? string.Empty,
|
||||
szNode: ackOperatorNode ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms()
|
||||
{
|
||||
if (disposed) throw new ObjectDisposedException(nameof(WnWrapAlarmConsumer));
|
||||
lock (syncRoot)
|
||||
{
|
||||
List<MxAlarmSnapshotRecord> active = new List<MxAlarmSnapshotRecord>();
|
||||
foreach (MxAlarmSnapshotRecord record in latestSnapshot.Values)
|
||||
{
|
||||
if (record.State == MxAlarmStateKind.UnackAlm
|
||||
|| record.State == MxAlarmStateKind.AckAlm)
|
||||
{
|
||||
active.Add(record);
|
||||
}
|
||||
}
|
||||
return active;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synchronously poll the wnwrap consumer once and dispatch any
|
||||
/// transitions. STA-bound hosts drive polling by calling this from
|
||||
/// the thread that owns the COM object. The consumer deliberately
|
||||
/// owns no internal timer: a thread-pool timer would call the
|
||||
/// apartment-threaded COM object off its owning STA and can block
|
||||
/// indefinitely on cross-apartment marshaling when the STA is not
|
||||
/// pumping messages.
|
||||
/// </summary>
|
||||
public void PollOnce()
|
||||
{
|
||||
wwAlarmConsumerClass? com;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed || !subscribed) return;
|
||||
com = client;
|
||||
}
|
||||
if (com is null) return;
|
||||
|
||||
object xmlObj = string.Empty;
|
||||
com.GetXmlCurrentAlarms2(maxAlmCnt: maxAlarmsPerFetch, vartCurrentXmlAlarms: out xmlObj);
|
||||
string xml = xmlObj?.ToString() ?? string.Empty;
|
||||
if (xml.Length == 0) return;
|
||||
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next = ParseSnapshotXml(xml);
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions;
|
||||
lock (syncRoot)
|
||||
{
|
||||
transitions = ComputeTransitions(latestSnapshot, next);
|
||||
latestSnapshot.Clear();
|
||||
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
||||
{
|
||||
latestSnapshot[kv.Key] = kv.Value;
|
||||
}
|
||||
}
|
||||
|
||||
if (transitions.Count == 0) return;
|
||||
EventHandler<MxAlarmTransitionEvent>? handler = AlarmTransitionEmitted;
|
||||
if (handler is null) return;
|
||||
foreach (MxAlarmTransitionEvent transition in transitions)
|
||||
{
|
||||
handler.Invoke(this, transition);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pure snapshot-to-transitions diff. Compares the previous polled
|
||||
/// snapshot to the next snapshot and produces one
|
||||
/// <see cref="MxAlarmTransitionEvent"/> per state change. Used by
|
||||
/// <see cref="PollOnce"/> after a successful
|
||||
/// <c>GetXmlCurrentAlarms2</c> call; exposed as <c>internal static</c>
|
||||
/// so the diff rules can be unit-tested without driving the
|
||||
/// wnwrapConsumer COM object (Worker.Tests-022).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>Rules:</para>
|
||||
/// <list type="bullet">
|
||||
/// <item><description>A GUID present in <paramref name="next"/> but not in <paramref name="previous"/> produces a transition with <see cref="MxAlarmStateKind.Unspecified"/> as the previous state — first sighting.</description></item>
|
||||
/// <item><description>A GUID present in both with the same <see cref="MxAlarmSnapshotRecord.State"/> produces no transition.</description></item>
|
||||
/// <item><description>A GUID present in both with a different <see cref="MxAlarmSnapshotRecord.State"/> produces a transition carrying the prior state.</description></item>
|
||||
/// <item><description>A GUID present in <paramref name="previous"/> but absent from <paramref name="next"/> produces no transition. AVEVA drops cleared alarms from the active set; the snapshot simply stops mentioning them.</description></item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
/// <param name="previous">The snapshot from the previous poll (or empty on first call).</param>
|
||||
/// <param name="next">The snapshot just parsed from <c>GetXmlCurrentAlarms2</c>.</param>
|
||||
/// <returns>One transition per state change in <paramref name="next"/>.</returns>
|
||||
internal static IReadOnlyList<MxAlarmTransitionEvent> ComputeTransitions(
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> previous,
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> next)
|
||||
{
|
||||
if (previous is null) throw new ArgumentNullException(nameof(previous));
|
||||
if (next is null) throw new ArgumentNullException(nameof(next));
|
||||
|
||||
List<MxAlarmTransitionEvent> transitions = new List<MxAlarmTransitionEvent>();
|
||||
foreach (KeyValuePair<Guid, MxAlarmSnapshotRecord> kv in next)
|
||||
{
|
||||
MxAlarmStateKind previousState = MxAlarmStateKind.Unspecified;
|
||||
if (previous.TryGetValue(kv.Key, out MxAlarmSnapshotRecord? prev))
|
||||
{
|
||||
previousState = prev.State;
|
||||
if (previousState == kv.Value.State) continue; // no transition
|
||||
}
|
||||
transitions.Add(new MxAlarmTransitionEvent
|
||||
{
|
||||
Record = kv.Value,
|
||||
PreviousState = previousState,
|
||||
});
|
||||
}
|
||||
return transitions;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parse the XML payload returned by <c>GetXmlCurrentAlarms2</c>
|
||||
/// into a GUID-keyed dictionary. Records with malformed GUIDs are
|
||||
/// silently dropped (no fault is recorded — the next poll will
|
||||
/// resync).
|
||||
/// </summary>
|
||||
public static Dictionary<Guid, MxAlarmSnapshotRecord> ParseSnapshotXml(string xml)
|
||||
{
|
||||
Dictionary<Guid, MxAlarmSnapshotRecord> records =
|
||||
new Dictionary<Guid, MxAlarmSnapshotRecord>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(xml)) return records;
|
||||
|
||||
XmlDocument doc = new XmlDocument();
|
||||
doc.LoadXml(xml);
|
||||
XmlNodeList? alarmNodes = doc.SelectNodes("/ALARM_RECORDS/ALARM");
|
||||
if (alarmNodes is null) return records;
|
||||
|
||||
foreach (XmlNode alarmNode in alarmNodes)
|
||||
{
|
||||
string guidHex = TextOf(alarmNode, "GUID");
|
||||
if (!TryParseHexGuid(guidHex, out Guid guid)) continue;
|
||||
|
||||
string xmlDate = TextOf(alarmNode, "DATE");
|
||||
string xmlTime = TextOf(alarmNode, "TIME");
|
||||
int gmtOffset = ParseInt(TextOf(alarmNode, "GMTOFFSET"));
|
||||
int dstAdjust = ParseInt(TextOf(alarmNode, "DSTADJUST"));
|
||||
DateTime tsUtc = AlarmRecordTransitionMapper.ParseTransitionTimestampUtc(
|
||||
xmlDate, xmlTime, gmtOffset, dstAdjust);
|
||||
|
||||
records[guid] = new MxAlarmSnapshotRecord
|
||||
{
|
||||
AlarmGuid = guid,
|
||||
TransitionTimestampUtc = tsUtc,
|
||||
ProviderNode = TextOf(alarmNode, "PROVIDER_NODE"),
|
||||
ProviderName = TextOf(alarmNode, "PROVIDER_NAME"),
|
||||
Group = TextOf(alarmNode, "GROUP"),
|
||||
TagName = TextOf(alarmNode, "TAGNAME"),
|
||||
Type = TextOf(alarmNode, "TYPE"),
|
||||
Value = TextOf(alarmNode, "VALUE"),
|
||||
Limit = TextOf(alarmNode, "LIMIT"),
|
||||
Priority = ParseInt(TextOf(alarmNode, "PRIORITY")),
|
||||
State = AlarmRecordTransitionMapper.ParseStateKind(TextOf(alarmNode, "STATE")),
|
||||
OperatorNode = TextOf(alarmNode, "OPERATOR_NODE"),
|
||||
OperatorName = TextOf(alarmNode, "OPERATOR_NAME"),
|
||||
AlarmComment = TextOf(alarmNode, "ALARM_COMMENT"),
|
||||
};
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
private static string TextOf(XmlNode parent, string childName)
|
||||
{
|
||||
XmlNode? node = parent.SelectSingleNode(childName);
|
||||
return node?.InnerText ?? string.Empty;
|
||||
}
|
||||
|
||||
private static int ParseInt(string text)
|
||||
{
|
||||
return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int n)
|
||||
? n : 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// wnwrap's XML <c>GUID</c> field is a 32-char hex string with no
|
||||
/// dashes (e.g. <c>"BCC4705395424D65BDAABCDEA6A32A73"</c>). Convert
|
||||
/// to <see cref="Guid"/>'s canonical 8-4-4-4-12 layout.
|
||||
/// </summary>
|
||||
public static bool TryParseHexGuid(string? hex, out Guid guid)
|
||||
{
|
||||
guid = Guid.Empty;
|
||||
if (string.IsNullOrWhiteSpace(hex)) return false;
|
||||
string trimmed = hex!.Trim();
|
||||
if (Guid.TryParse(trimmed, out guid)) return true;
|
||||
if (trimmed.Length != 32) return false;
|
||||
string canonical =
|
||||
trimmed.Substring(0, 8) + "-" +
|
||||
trimmed.Substring(8, 4) + "-" +
|
||||
trimmed.Substring(12, 4) + "-" +
|
||||
trimmed.Substring(16, 4) + "-" +
|
||||
trimmed.Substring(20, 12);
|
||||
return Guid.TryParse(canonical, out guid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Compose the XML payload <c>SetXmlAlarmQuery</c> expects from a
|
||||
/// canonical subscription expression
|
||||
/// (<c>\\<machine>\Galaxy!<area></c>). The wnwrap
|
||||
/// consumer mangles the round-trip but evidently still needs the
|
||||
/// call — without it <c>GetXmlCurrentAlarms2</c> fails with
|
||||
/// E_FAIL. Best-effort parse: if the subscription doesn't decompose
|
||||
/// cleanly, fall back to a permissive ALL-priority/ALL-state form
|
||||
/// so the worker doesn't fail to start.
|
||||
/// </summary>
|
||||
internal static string ComposeXmlAlarmQuery(string subscription)
|
||||
{
|
||||
string node = Environment.MachineName;
|
||||
string provider = "Galaxy";
|
||||
string group = string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(subscription))
|
||||
{
|
||||
// Strip leading backslashes from "\\<node>\..." form.
|
||||
string trimmed = subscription.TrimStart('\\');
|
||||
int slash = trimmed.IndexOf('\\');
|
||||
if (slash > 0)
|
||||
{
|
||||
node = trimmed.Substring(0, slash);
|
||||
trimmed = trimmed.Substring(slash + 1);
|
||||
}
|
||||
int bang = trimmed.IndexOf('!');
|
||||
if (bang > 0)
|
||||
{
|
||||
provider = trimmed.Substring(0, bang);
|
||||
group = trimmed.Substring(bang + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
provider = trimmed;
|
||||
}
|
||||
}
|
||||
|
||||
System.Text.StringBuilder sb = new System.Text.StringBuilder();
|
||||
sb.Append("<QUERIES FROM_PRIORITY=\"1\" TO_PRIORITY=\"999\" ALARM_STATE=\"ALL\" DISPLAY_MODE=\"Summary\">");
|
||||
sb.Append("<QUERY>");
|
||||
sb.Append("<NODE>").Append(node).Append("</NODE>");
|
||||
sb.Append("<PROVIDER>").Append(provider).Append("</PROVIDER>");
|
||||
if (!string.IsNullOrEmpty(group))
|
||||
{
|
||||
sb.Append("<GROUP>").Append(group).Append("</GROUP>");
|
||||
}
|
||||
sb.Append("</QUERY>");
|
||||
sb.Append("</QUERIES>");
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
private static VBGUID ToVbGuid(Guid g)
|
||||
{
|
||||
byte[] bytes = g.ToByteArray();
|
||||
// Guid byte layout: int32-LE + int16-LE + int16-LE + 8 bytes (Data4).
|
||||
VBGUID vb = new VBGUID
|
||||
{
|
||||
Data1 = BitConverter.ToInt32(bytes, 0),
|
||||
Data2 = BitConverter.ToInt16(bytes, 4),
|
||||
Data3 = BitConverter.ToInt16(bytes, 6),
|
||||
Data4 = new byte[8],
|
||||
};
|
||||
Array.Copy(bytes, 8, vb.Data4, 0, 8);
|
||||
return vb;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
wwAlarmConsumerClass? clientToDispose;
|
||||
wwAlarmConsumerClass? ackClientToDispose;
|
||||
lock (syncRoot)
|
||||
{
|
||||
if (disposed) return;
|
||||
disposed = true;
|
||||
clientToDispose = client;
|
||||
client = null;
|
||||
ackClientToDispose = ackClient;
|
||||
ackClient = null;
|
||||
}
|
||||
ReleaseConsumerCom(clientToDispose);
|
||||
ReleaseConsumerCom(ackClientToDispose);
|
||||
}
|
||||
|
||||
private static void ReleaseConsumerCom(wwAlarmConsumerClass? consumer)
|
||||
{
|
||||
if (consumer is null) return;
|
||||
try { consumer.DeregisterConsumer(); } catch { /* swallow */ }
|
||||
try { consumer.UninitializeConsumer(); } catch { /* swallow */ }
|
||||
if (Marshal.IsComObject(consumer))
|
||||
{
|
||||
try { Marshal.FinalReleaseComObject(consumer); } catch { /* swallow */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
||||
|
||||
public sealed class WorkerRuntimeHeartbeatSnapshot
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="WorkerRuntimeHeartbeatSnapshot"/> class.</summary>
|
||||
/// <param name="lastStaActivityUtc">Timestamp of the last STA thread activity in UTC.</param>
|
||||
/// <param name="pendingCommandCount">Number of commands awaiting processing.</param>
|
||||
/// <param name="outboundEventQueueDepth">Current depth of the worker event queue.</param>
|
||||
/// <param name="lastEventSequence">Sequence number of the most recent event.</param>
|
||||
/// <param name="currentCommandCorrelationId">Correlation ID of the in-flight command.</param>
|
||||
public WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset lastStaActivityUtc,
|
||||
uint pendingCommandCount,
|
||||
uint outboundEventQueueDepth,
|
||||
ulong lastEventSequence,
|
||||
string currentCommandCorrelationId)
|
||||
{
|
||||
LastStaActivityUtc = lastStaActivityUtc;
|
||||
PendingCommandCount = pendingCommandCount;
|
||||
OutboundEventQueueDepth = outboundEventQueueDepth;
|
||||
LastEventSequence = lastEventSequence;
|
||||
CurrentCommandCorrelationId = currentCommandCorrelationId ?? string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Gets the last STA activity timestamp in UTC.</summary>
|
||||
public DateTimeOffset LastStaActivityUtc { get; }
|
||||
|
||||
/// <summary>Gets the pending command count.</summary>
|
||||
public uint PendingCommandCount { get; }
|
||||
|
||||
/// <summary>Gets the current depth of the worker event queue.</summary>
|
||||
public uint OutboundEventQueueDepth { get; }
|
||||
|
||||
/// <summary>Gets the sequence number of the most recent event.</summary>
|
||||
public ulong LastEventSequence { get; }
|
||||
|
||||
/// <summary>Gets the correlation ID of the in-flight command.</summary>
|
||||
public string CurrentCommandCorrelationId { get; }
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
using ZB.MOM.WW.MxGateway.Worker;
|
||||
|
||||
return WorkerApplication.Run(args);
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes and uninitializes the COM apartment for the STA thread.
|
||||
/// </summary>
|
||||
public interface IStaComApartmentInitializer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes the COM apartment on the STA thread.
|
||||
/// </summary>
|
||||
void Initialize();
|
||||
|
||||
/// <summary>
|
||||
/// Uninitializes the COM apartment on the STA thread.
|
||||
/// </summary>
|
||||
void Uninitialize();
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public interface IStaCommandExecutor
|
||||
{
|
||||
/// <summary>Executes a command on the STA thread.</summary>
|
||||
/// <param name="command">The command to execute.</param>
|
||||
/// <returns>The command reply.</returns>
|
||||
MxCommandReply Execute(StaCommand command);
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
internal interface IStaWorkItem
|
||||
{
|
||||
/// <summary>Cancels the work item before it executes on the STA thread.</summary>
|
||||
void CancelBeforeExecution();
|
||||
|
||||
/// <summary>Executes the work item on the STA thread.</summary>
|
||||
void Execute();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaComApartmentInitializer : IStaComApartmentInitializer
|
||||
{
|
||||
private const uint CoInitializeApartmentThreaded = 0x2;
|
||||
private const int SOk = 0;
|
||||
private const int SFalse = 1;
|
||||
|
||||
/// <summary>Initializes the COM apartment in single-threaded mode.</summary>
|
||||
public void Initialize()
|
||||
{
|
||||
int hresult = CoInitializeEx(IntPtr.Zero, CoInitializeApartmentThreaded);
|
||||
if (hresult != SOk && hresult != SFalse)
|
||||
{
|
||||
throw new COMException("Failed to initialize the worker STA COM apartment.", hresult);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Uninitializes the COM apartment.</summary>
|
||||
public void Uninitialize()
|
||||
{
|
||||
CoUninitialize();
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern int CoInitializeEx(IntPtr reserved, uint coInit);
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
private static extern void CoUninitialize();
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaCommand
|
||||
{
|
||||
/// <summary>Initializes a new instance of the <see cref="StaCommand"/> class.</summary>
|
||||
/// <param name="sessionId">Identifier of the session.</param>
|
||||
/// <param name="correlationId">Correlation identifier for the command.</param>
|
||||
/// <param name="command">The MXAccess command to execute.</param>
|
||||
/// <param name="enqueueTimestamp">Timestamp when the command was enqueued.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
public StaCommand(
|
||||
string sessionId,
|
||||
string correlationId,
|
||||
MxCommand command,
|
||||
Timestamp? enqueueTimestamp = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
{
|
||||
throw new ArgumentException("STA command requires a session id.", nameof(sessionId));
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
throw new ArgumentException("STA command requires a correlation id.", nameof(correlationId));
|
||||
}
|
||||
|
||||
SessionId = sessionId;
|
||||
CorrelationId = correlationId;
|
||||
Command = command ?? throw new ArgumentNullException(nameof(command));
|
||||
EnqueueTimestamp = enqueueTimestamp ?? Timestamp.FromDateTime(DateTime.UtcNow);
|
||||
CancellationToken = cancellationToken;
|
||||
}
|
||||
|
||||
/// <summary>Gets the session ID for the STA command.</summary>
|
||||
public string SessionId { get; }
|
||||
|
||||
/// <summary>Gets the correlation ID for the STA command.</summary>
|
||||
public string CorrelationId { get; }
|
||||
|
||||
/// <summary>Gets the MXAccess command to execute.</summary>
|
||||
public MxCommand Command { get; }
|
||||
|
||||
/// <summary>Gets the timestamp when the command was enqueued.</summary>
|
||||
public Timestamp EnqueueTimestamp { get; }
|
||||
|
||||
/// <summary>Gets the token to cancel the asynchronous operation.</summary>
|
||||
public CancellationToken CancellationToken { get; }
|
||||
|
||||
/// <summary>Gets the kind of the MXAccess command.</summary>
|
||||
public MxCommandKind Kind => Command.Kind;
|
||||
|
||||
/// <summary>Gets the method name of the command.</summary>
|
||||
public string MethodName => Kind.ToString();
|
||||
}
|
||||
@@ -0,0 +1,403 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Conversion;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaCommandDispatcher
|
||||
{
|
||||
public const int DefaultMaxPendingCommands = 128;
|
||||
|
||||
private readonly HResultConverter hresultConverter;
|
||||
private readonly IStaCommandExecutor commandExecutor;
|
||||
private readonly Queue<QueuedStaCommand> commandQueue = new();
|
||||
private readonly StaRuntime staRuntime;
|
||||
private readonly int maxPendingCommands;
|
||||
private readonly object gate = new();
|
||||
private bool drainActive;
|
||||
private bool shutdownRequested;
|
||||
private string currentCommandCorrelationId = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaCommandDispatcher"/> with default converter.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="commandExecutor">Command executor.</param>
|
||||
public StaCommandDispatcher(
|
||||
StaRuntime staRuntime,
|
||||
IStaCommandExecutor commandExecutor)
|
||||
: this(staRuntime, commandExecutor, new HResultConverter())
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaCommandDispatcher"/> with custom converter.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="commandExecutor">Command executor.</param>
|
||||
/// <param name="hresultConverter">HResult converter.</param>
|
||||
public StaCommandDispatcher(
|
||||
StaRuntime staRuntime,
|
||||
IStaCommandExecutor commandExecutor,
|
||||
HResultConverter hresultConverter)
|
||||
: this(staRuntime, commandExecutor, hresultConverter, DefaultMaxPendingCommands)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaCommandDispatcher"/> with all parameters.
|
||||
/// </summary>
|
||||
/// <param name="staRuntime">STA thread runtime.</param>
|
||||
/// <param name="commandExecutor">Command executor.</param>
|
||||
/// <param name="hresultConverter">HResult converter.</param>
|
||||
/// <param name="maxPendingCommands">Maximum pending commands allowed.</param>
|
||||
public StaCommandDispatcher(
|
||||
StaRuntime staRuntime,
|
||||
IStaCommandExecutor commandExecutor,
|
||||
HResultConverter hresultConverter,
|
||||
int maxPendingCommands)
|
||||
{
|
||||
if (maxPendingCommands <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(maxPendingCommands),
|
||||
"Max pending STA commands must be greater than zero.");
|
||||
}
|
||||
|
||||
this.staRuntime = staRuntime ?? throw new ArgumentNullException(nameof(staRuntime));
|
||||
this.commandExecutor = commandExecutor ?? throw new ArgumentNullException(nameof(commandExecutor));
|
||||
this.hresultConverter = hresultConverter ?? throw new ArgumentNullException(nameof(hresultConverter));
|
||||
this.maxPendingCommands = maxPendingCommands;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the count of pending commands in the queue.
|
||||
/// </summary>
|
||||
public int PendingCommandCount
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return commandQueue.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the correlation ID of the currently executing command.
|
||||
/// </summary>
|
||||
public string CurrentCommandCorrelationId
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return currentCommandCorrelationId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a command to the queue for asynchronous STA execution.
|
||||
/// </summary>
|
||||
/// <param name="command">The command to dispatch.</param>
|
||||
/// <returns>Task for the command reply.</returns>
|
||||
public Task<MxCommandReply> DispatchAsync(StaCommand command)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (shutdownRequested)
|
||||
{
|
||||
return Task.FromResult(CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
"The STA command dispatcher is shutting down."));
|
||||
}
|
||||
|
||||
if (commandQueue.Count >= maxPendingCommands)
|
||||
{
|
||||
return Task.FromResult(CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
$"The STA command dispatcher already has {maxPendingCommands} pending command(s)."));
|
||||
}
|
||||
|
||||
QueuedStaCommand queuedCommand = new(command);
|
||||
commandQueue.Enqueue(queuedCommand);
|
||||
|
||||
if (!drainActive)
|
||||
{
|
||||
drainActive = true;
|
||||
_ = DrainAsync();
|
||||
}
|
||||
|
||||
return queuedCommand.Task;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cancels a queued command by its correlation ID.
|
||||
/// </summary>
|
||||
/// <param name="correlationId">Correlation ID of the command to cancel.</param>
|
||||
/// <returns>True if the command was canceled; otherwise false.</returns>
|
||||
public bool CancelQueuedCommand(string correlationId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(correlationId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (commandQueue.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
bool canceled = false;
|
||||
Queue<QueuedStaCommand> retainedCommands = new(commandQueue.Count);
|
||||
while (commandQueue.Count > 0)
|
||||
{
|
||||
QueuedStaCommand queuedCommand = commandQueue.Dequeue();
|
||||
if (!canceled
|
||||
&& string.Equals(
|
||||
queuedCommand.Command.CorrelationId,
|
||||
correlationId,
|
||||
StringComparison.Ordinal))
|
||||
{
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
queuedCommand.Command,
|
||||
ProtocolStatusCode.Canceled,
|
||||
"The STA command was canceled before execution."));
|
||||
canceled = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
retainedCommands.Enqueue(queuedCommand);
|
||||
}
|
||||
|
||||
while (retainedCommands.Count > 0)
|
||||
{
|
||||
commandQueue.Enqueue(retainedCommands.Dequeue());
|
||||
}
|
||||
|
||||
return canceled;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests graceful shutdown, rejecting all queued commands.
|
||||
/// </summary>
|
||||
public void RequestShutdown()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
shutdownRequested = true;
|
||||
while (commandQueue.Count > 0)
|
||||
{
|
||||
QueuedStaCommand queuedCommand = commandQueue.Dequeue();
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
queuedCommand.Command,
|
||||
ProtocolStatusCode.WorkerUnavailable,
|
||||
"The STA command dispatcher is shutting down."));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Populates the given heartbeat with current dispatcher state.
|
||||
/// </summary>
|
||||
/// <param name="heartbeat">Heartbeat to populate.</param>
|
||||
public void PopulateHeartbeat(WorkerHeartbeat heartbeat)
|
||||
{
|
||||
if (heartbeat is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(heartbeat));
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
heartbeat.PendingCommandCount = (uint)commandQueue.Count;
|
||||
heartbeat.CurrentCommandCorrelationId = currentCommandCorrelationId;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task DrainAsync()
|
||||
{
|
||||
while (true)
|
||||
{
|
||||
QueuedStaCommand queuedCommand;
|
||||
lock (gate)
|
||||
{
|
||||
if (commandQueue.Count == 0)
|
||||
{
|
||||
drainActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
queuedCommand = commandQueue.Dequeue();
|
||||
}
|
||||
|
||||
await ExecuteQueuedCommandAsync(queuedCommand).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ExecuteQueuedCommandAsync(QueuedStaCommand queuedCommand)
|
||||
{
|
||||
StaCommand command = queuedCommand.Command;
|
||||
if (command.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
queuedCommand.Complete(CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.Canceled,
|
||||
"The STA command was canceled before execution."));
|
||||
return;
|
||||
}
|
||||
|
||||
SetCurrentCommand(command.CorrelationId);
|
||||
try
|
||||
{
|
||||
MxCommandReply reply = await staRuntime
|
||||
.InvokeAsync(() => commandExecutor.Execute(command))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
queuedCommand.Complete(NormalizeReply(command, reply));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
queuedCommand.Complete(CreateExceptionReply(command, exception));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SetCurrentCommand(string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetCurrentCommand(string correlationId)
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
currentCommandCorrelationId = correlationId;
|
||||
}
|
||||
}
|
||||
|
||||
private MxCommandReply NormalizeReply(
|
||||
StaCommand command,
|
||||
MxCommandReply reply)
|
||||
{
|
||||
if (reply is null)
|
||||
{
|
||||
return CreateRejectedReply(
|
||||
command,
|
||||
ProtocolStatusCode.ProtocolViolation,
|
||||
"STA command executor returned null.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reply.SessionId))
|
||||
{
|
||||
reply.SessionId = command.SessionId;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(reply.CorrelationId))
|
||||
{
|
||||
reply.CorrelationId = command.CorrelationId;
|
||||
}
|
||||
|
||||
if (reply.Kind == MxCommandKind.Unspecified)
|
||||
{
|
||||
reply.Kind = command.Kind;
|
||||
}
|
||||
|
||||
if (reply.ProtocolStatus is null)
|
||||
{
|
||||
reply.ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.Ok,
|
||||
Message = "OK",
|
||||
};
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private MxCommandReply CreateExceptionReply(
|
||||
StaCommand command,
|
||||
Exception exception)
|
||||
{
|
||||
HResultConversion conversion = hresultConverter.Convert(exception);
|
||||
MxCommandReply reply = CreateBaseReply(command);
|
||||
reply.ProtocolStatus = conversion.ProtocolStatus;
|
||||
reply.Hresult = conversion.HResult;
|
||||
reply.DiagnosticMessage = conversion.DiagnosticMessage;
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateRejectedReply(
|
||||
StaCommand command,
|
||||
ProtocolStatusCode statusCode,
|
||||
string message)
|
||||
{
|
||||
MxCommandReply reply = CreateBaseReply(command);
|
||||
reply.ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = statusCode,
|
||||
Message = message,
|
||||
};
|
||||
reply.DiagnosticMessage = message;
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private static MxCommandReply CreateBaseReply(StaCommand command)
|
||||
{
|
||||
return new MxCommandReply
|
||||
{
|
||||
SessionId = command.SessionId,
|
||||
CorrelationId = command.CorrelationId,
|
||||
Kind = command.Kind,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class QueuedStaCommand
|
||||
{
|
||||
private readonly TaskCompletionSource<MxCommandReply> completion = new(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="QueuedStaCommand"/>.
|
||||
/// </summary>
|
||||
/// <param name="command">The STA command to queue.</param>
|
||||
public QueuedStaCommand(StaCommand command)
|
||||
{
|
||||
Command = command;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the queued STA command.
|
||||
/// </summary>
|
||||
public StaCommand Command { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the task representing the command's completion.
|
||||
/// </summary>
|
||||
public Task<MxCommandReply> Task => completion.Task;
|
||||
|
||||
/// <summary>
|
||||
/// Completes the command with the given reply.
|
||||
/// </summary>
|
||||
/// <param name="reply">The command reply.</param>
|
||||
public void Complete(MxCommandReply reply)
|
||||
{
|
||||
completion.TrySetResult(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using Microsoft.Win32.SafeHandles;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>Pumps Windows messages on the STA thread to allow MXAccess COM events to deliver.</summary>
|
||||
public sealed class StaMessagePump
|
||||
{
|
||||
private const uint Infinite = 0xFFFFFFFF;
|
||||
private const uint MsgWaitFailed = 0xFFFFFFFF;
|
||||
private const uint MwmoInputAvailable = 0x0004;
|
||||
private const uint PmRemove = 0x0001;
|
||||
private const uint QsAllInput = 0x04FF;
|
||||
|
||||
/// <summary>Waits for a command wake event or Windows messages, pumping any pending messages.</summary>
|
||||
/// <param name="commandWakeEvent">Event to signal when work is available.</param>
|
||||
/// <param name="timeout">Maximum time to wait; InfiniteTimeSpan waits indefinitely.</param>
|
||||
public void WaitForWorkOrMessages(WaitHandle commandWakeEvent, TimeSpan timeout)
|
||||
{
|
||||
if (commandWakeEvent is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(commandWakeEvent));
|
||||
}
|
||||
|
||||
uint timeoutMilliseconds = ToTimeoutMilliseconds(timeout);
|
||||
|
||||
SafeWaitHandle safeHandle = commandWakeEvent.SafeWaitHandle;
|
||||
IntPtr[] handles = [safeHandle.DangerousGetHandle()];
|
||||
uint result = MsgWaitForMultipleObjectsEx(
|
||||
(uint)handles.Length,
|
||||
handles,
|
||||
timeoutMilliseconds,
|
||||
QsAllInput,
|
||||
MwmoInputAvailable);
|
||||
|
||||
if (result == MsgWaitFailed)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The worker STA message pump failed while waiting for command work or Windows messages.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pumps and dispatches all pending Windows messages, returning the count processed.</summary>
|
||||
public int PumpPendingMessages()
|
||||
{
|
||||
int pumpedMessages = 0;
|
||||
|
||||
while (PeekMessage(out NativeMessage message, IntPtr.Zero, 0, 0, PmRemove))
|
||||
{
|
||||
TranslateMessage(ref message);
|
||||
DispatchMessage(ref message);
|
||||
pumpedMessages++;
|
||||
}
|
||||
|
||||
return pumpedMessages;
|
||||
}
|
||||
|
||||
private static uint ToTimeoutMilliseconds(TimeSpan timeout)
|
||||
{
|
||||
if (timeout == Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
return Infinite;
|
||||
}
|
||||
|
||||
if (timeout <= TimeSpan.Zero)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
return timeout.TotalMilliseconds >= uint.MaxValue
|
||||
? uint.MaxValue - 1
|
||||
: (uint)Math.Ceiling(timeout.TotalMilliseconds);
|
||||
}
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern uint MsgWaitForMultipleObjectsEx(
|
||||
uint count,
|
||||
IntPtr[] handles,
|
||||
uint milliseconds,
|
||||
uint wakeMask,
|
||||
uint flags);
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
private static extern bool PeekMessage(
|
||||
out NativeMessage message,
|
||||
IntPtr windowHandle,
|
||||
uint messageFilterMin,
|
||||
uint messageFilterMax,
|
||||
uint removeMessage);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern bool TranslateMessage(ref NativeMessage message);
|
||||
|
||||
[DllImport("user32.dll")]
|
||||
private static extern IntPtr DispatchMessage(ref NativeMessage message);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeMessage
|
||||
{
|
||||
public IntPtr WindowHandle;
|
||||
public uint Message;
|
||||
public UIntPtr WParam;
|
||||
public IntPtr LParam;
|
||||
public uint Time;
|
||||
public NativePoint Point;
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativePoint
|
||||
{
|
||||
public int X;
|
||||
public int Y;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
public sealed class StaRuntime : IDisposable
|
||||
{
|
||||
private readonly IStaComApartmentInitializer comApartmentInitializer;
|
||||
private readonly StaMessagePump messagePump;
|
||||
private readonly ConcurrentQueue<IStaWorkItem> commandQueue = new();
|
||||
private readonly AutoResetEvent commandWakeEvent = new(false);
|
||||
private readonly ManualResetEventSlim startedEvent = new(false);
|
||||
private readonly ManualResetEventSlim stoppedEvent = new(false);
|
||||
private readonly object gate = new();
|
||||
private readonly Thread staThread;
|
||||
private readonly TimeSpan idlePumpInterval;
|
||||
private bool disposed;
|
||||
private bool startRequested;
|
||||
private bool shutdownRequested;
|
||||
private Exception? startupException;
|
||||
private long lastActivityUtcTicks;
|
||||
private bool comInitialized;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntime"/> with default dependencies.
|
||||
/// </summary>
|
||||
public StaRuntime()
|
||||
: this(new StaComApartmentInitializer(), new StaMessagePump(), TimeSpan.FromMilliseconds(50))
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntime"/> with custom dependencies.
|
||||
/// </summary>
|
||||
/// <param name="comApartmentInitializer">COM apartment initializer.</param>
|
||||
/// <param name="messagePump">Message pump for the STA thread.</param>
|
||||
/// <param name="idlePumpInterval">Interval for idle message pump waits.</param>
|
||||
public StaRuntime(
|
||||
IStaComApartmentInitializer comApartmentInitializer,
|
||||
StaMessagePump messagePump,
|
||||
TimeSpan idlePumpInterval)
|
||||
{
|
||||
this.comApartmentInitializer = comApartmentInitializer
|
||||
?? throw new ArgumentNullException(nameof(comApartmentInitializer));
|
||||
this.messagePump = messagePump ?? throw new ArgumentNullException(nameof(messagePump));
|
||||
|
||||
if (idlePumpInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(
|
||||
nameof(idlePumpInterval),
|
||||
"The idle pump interval must be greater than zero.");
|
||||
}
|
||||
|
||||
this.idlePumpInterval = idlePumpInterval;
|
||||
lastActivityUtcTicks = DateTimeOffset.UtcNow.UtcTicks;
|
||||
staThread = new Thread(ThreadMain)
|
||||
{
|
||||
IsBackground = true,
|
||||
Name = "MxGateway.Worker.STA"
|
||||
};
|
||||
staThread.SetApartmentState(ApartmentState.STA);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the managed thread ID of the STA thread.
|
||||
/// </summary>
|
||||
public int? StaThreadId { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the timestamp of the last STA thread activity.
|
||||
/// </summary>
|
||||
public DateTimeOffset LastActivityUtc =>
|
||||
new(new DateTime(Volatile.Read(ref lastActivityUtcTicks), DateTimeKind.Utc));
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the STA runtime is currently running.
|
||||
/// </summary>
|
||||
public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet;
|
||||
|
||||
/// <summary>
|
||||
/// Pumps any pending Windows messages on the calling thread. Intended
|
||||
/// for commands that synchronously hold the STA (e.g. ReadBulk) and
|
||||
/// must allow inbound MXAccess COM events to dispatch while they
|
||||
/// wait. Callers must already be on the STA; the method is otherwise
|
||||
/// safe (PeekMessage simply finds no messages).
|
||||
/// </summary>
|
||||
public int PumpPendingMessages() => messagePump.PumpPendingMessages();
|
||||
|
||||
/// <summary>
|
||||
/// Starts the STA thread.
|
||||
/// </summary>
|
||||
public void Start()
|
||||
{
|
||||
ThrowIfDisposed();
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (shutdownRequested)
|
||||
{
|
||||
throw new StaRuntimeShutdownException();
|
||||
}
|
||||
|
||||
if (!startRequested)
|
||||
{
|
||||
startRequested = true;
|
||||
staThread.Start();
|
||||
}
|
||||
}
|
||||
|
||||
startedEvent.Wait();
|
||||
if (startupException is not null)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"The worker STA runtime failed to initialize.",
|
||||
startupException);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes an action on the STA thread asynchronously.
|
||||
/// </summary>
|
||||
/// <param name="command">Action to invoke on the STA thread.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task that completes when the action executes.</returns>
|
||||
public Task InvokeAsync(Action command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
return InvokeAsync(
|
||||
() =>
|
||||
{
|
||||
command();
|
||||
return true;
|
||||
},
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Invokes a function on the STA thread asynchronously.
|
||||
/// </summary>
|
||||
/// <typeparam name="T">Return type of the function.</typeparam>
|
||||
/// <param name="command">Function to invoke on the STA thread.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Task that returns the function result.</returns>
|
||||
public Task<T> InvokeAsync<T>(Func<T> command, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (command is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(command));
|
||||
}
|
||||
|
||||
ThrowIfDisposed();
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return Task.FromCanceled<T>(cancellationToken);
|
||||
}
|
||||
|
||||
StaWorkItem<T> workItem = new(command, cancellationToken);
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (shutdownRequested)
|
||||
{
|
||||
return Task.FromException<T>(new StaRuntimeShutdownException());
|
||||
}
|
||||
|
||||
commandQueue.Enqueue(workItem);
|
||||
}
|
||||
|
||||
commandWakeEvent.Set();
|
||||
return workItem.Task;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Requests graceful shutdown of the STA runtime within a timeout.
|
||||
/// </summary>
|
||||
/// <param name="timeout">Maximum time to wait for shutdown.</param>
|
||||
/// <returns>True if shutdown completed; otherwise false.</returns>
|
||||
public bool Shutdown(TimeSpan timeout)
|
||||
{
|
||||
if (timeout < TimeSpan.Zero && timeout != Timeout.InfiniteTimeSpan)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(timeout));
|
||||
}
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
shutdownRequested = true;
|
||||
}
|
||||
|
||||
commandWakeEvent.Set();
|
||||
|
||||
if (!startedEvent.IsSet && !staThread.IsAlive)
|
||||
{
|
||||
CancelQueuedCommands();
|
||||
stoppedEvent.Set();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool stopped = stoppedEvent.Wait(timeout);
|
||||
if (stopped)
|
||||
{
|
||||
CancelQueuedCommands();
|
||||
}
|
||||
|
||||
return stopped;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases resources used by the STA runtime.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
bool stopped = Shutdown(TimeSpan.FromSeconds(5));
|
||||
if (stopped)
|
||||
{
|
||||
commandWakeEvent.Dispose();
|
||||
startedEvent.Dispose();
|
||||
stoppedEvent.Dispose();
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
}
|
||||
|
||||
private void ThreadMain()
|
||||
{
|
||||
try
|
||||
{
|
||||
StaThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
comApartmentInitializer.Initialize();
|
||||
comInitialized = true;
|
||||
MarkActivity();
|
||||
startedEvent.Set();
|
||||
|
||||
while (!IsShutdownRequested())
|
||||
{
|
||||
ProcessQueuedCommands();
|
||||
messagePump.WaitForWorkOrMessages(commandWakeEvent, idlePumpInterval);
|
||||
messagePump.PumpPendingMessages();
|
||||
MarkActivity();
|
||||
}
|
||||
|
||||
ProcessQueuedCommands();
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
startupException = exception;
|
||||
startedEvent.Set();
|
||||
}
|
||||
finally
|
||||
{
|
||||
CancelQueuedCommands();
|
||||
try
|
||||
{
|
||||
if (comInitialized)
|
||||
{
|
||||
comApartmentInitializer.Uninitialize();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
MarkActivity();
|
||||
stoppedEvent.Set();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void ProcessQueuedCommands()
|
||||
{
|
||||
while (commandQueue.TryDequeue(out IStaWorkItem? workItem))
|
||||
{
|
||||
MarkActivity();
|
||||
workItem.Execute();
|
||||
MarkActivity();
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelQueuedCommands()
|
||||
{
|
||||
while (commandQueue.TryDequeue(out IStaWorkItem? workItem))
|
||||
{
|
||||
workItem.CancelBeforeExecution();
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsShutdownRequested()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return shutdownRequested;
|
||||
}
|
||||
}
|
||||
|
||||
private void MarkActivity()
|
||||
{
|
||||
Volatile.Write(ref lastActivityUtcTicks, DateTimeOffset.UtcNow.UtcTicks);
|
||||
}
|
||||
|
||||
private void ThrowIfDisposed()
|
||||
{
|
||||
if (disposed)
|
||||
{
|
||||
throw new ObjectDisposedException(nameof(StaRuntime));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
using System;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Thrown by <see cref="StaRuntime"/> when an operation is rejected because
|
||||
/// the runtime is shutting down (or has already shut down). The dedicated
|
||||
/// type lets callers distinguish a graceful shutdown signal — which should
|
||||
/// stop their work loops without recording a fault — from a genuine
|
||||
/// programming-error <see cref="InvalidOperationException"/> such as the
|
||||
/// STA-affinity assertion in <c>MxAccessStaSession.AssertOnAlarmConsumerThread</c>.
|
||||
/// It inherits from <see cref="InvalidOperationException"/> so existing
|
||||
/// callers that catch the latter remain source-compatible.
|
||||
/// </summary>
|
||||
public sealed class StaRuntimeShutdownException : InvalidOperationException
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntimeShutdownException"/>
|
||||
/// with a default message.
|
||||
/// </summary>
|
||||
public StaRuntimeShutdownException()
|
||||
: base("The worker STA runtime is shutting down.")
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of <see cref="StaRuntimeShutdownException"/>
|
||||
/// with the specified message.
|
||||
/// </summary>
|
||||
/// <param name="message">Diagnostic message.</param>
|
||||
public StaRuntimeShutdownException(string message)
|
||||
: base(message)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Encapsulates a work item to be executed on an STA thread with cancellation support.
|
||||
/// </summary>
|
||||
internal sealed class StaWorkItem<T> : IStaWorkItem
|
||||
{
|
||||
private readonly Func<T> command;
|
||||
private readonly CancellationToken cancellationToken;
|
||||
private readonly CancellationTokenRegistration cancellationRegistration;
|
||||
private int started;
|
||||
|
||||
/// <summary>Initializes a work item with a command and cancellation token.</summary>
|
||||
/// <param name="command">Function to execute on the STA thread.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the work item.</param>
|
||||
public StaWorkItem(Func<T> command, CancellationToken cancellationToken)
|
||||
{
|
||||
this.command = command ?? throw new ArgumentNullException(nameof(command));
|
||||
this.cancellationToken = cancellationToken;
|
||||
Completion = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
if (cancellationToken.CanBeCanceled)
|
||||
{
|
||||
cancellationRegistration = cancellationToken.Register(
|
||||
() =>
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref started, 1, 0) == 0)
|
||||
{
|
||||
Completion.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Gets the task that completes when work completes.</summary>
|
||||
public Task<T> Task => Completion.Task;
|
||||
|
||||
private TaskCompletionSource<T> Completion { get; }
|
||||
|
||||
/// <summary>Cancels the work item before execution begins.</summary>
|
||||
public void CancelBeforeExecution()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref started, 1, 0) == 0)
|
||||
{
|
||||
Completion.TrySetCanceled(cancellationToken);
|
||||
cancellationRegistration.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Executes the work item command.</summary>
|
||||
public void Execute()
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref started, 1, 0) != 0)
|
||||
{
|
||||
cancellationRegistration.Dispose();
|
||||
return;
|
||||
}
|
||||
|
||||
cancellationRegistration.Dispose();
|
||||
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
Completion.TrySetCanceled(cancellationToken);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Completion.TrySetResult(command());
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
Completion.TrySetException(exception);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Ipc;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker;
|
||||
|
||||
/// <summary>Entry point for the worker process.</summary>
|
||||
public static class WorkerApplication
|
||||
{
|
||||
/// <summary>Initializes and runs the worker with default environment and logging.</summary>
|
||||
/// <param name="args">Command-line arguments.</param>
|
||||
public static int Run(string[] args)
|
||||
{
|
||||
return Run(
|
||||
args,
|
||||
new EnvironmentVariableWorkerEnvironment(),
|
||||
new WorkerConsoleLogger(Console.Error));
|
||||
}
|
||||
|
||||
/// <summary>Initializes and runs the worker with custom environment and logging.</summary>
|
||||
/// <param name="args">Command-line arguments.</param>
|
||||
/// <param name="environment">Worker environment for resolving configuration.</param>
|
||||
/// <param name="logger">Worker logger for diagnostics.</param>
|
||||
public static int Run(
|
||||
string[] args,
|
||||
IWorkerEnvironment environment,
|
||||
IWorkerLogger logger)
|
||||
{
|
||||
return Run(
|
||||
args,
|
||||
environment,
|
||||
logger,
|
||||
new WorkerPipeClient(logger));
|
||||
}
|
||||
|
||||
/// <summary>Parses arguments, bootstraps the handshake, and runs the worker until shutdown.</summary>
|
||||
/// <param name="args">Command-line arguments.</param>
|
||||
/// <param name="environment">Worker environment for resolving configuration.</param>
|
||||
/// <param name="logger">Worker logger for diagnostics.</param>
|
||||
/// <param name="pipeClient">Named pipe client for gateway communication.</param>
|
||||
public static int Run(
|
||||
string[] args,
|
||||
IWorkerEnvironment environment,
|
||||
IWorkerLogger logger,
|
||||
IWorkerPipeClient pipeClient)
|
||||
{
|
||||
if (args is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(args));
|
||||
}
|
||||
|
||||
if (environment is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(environment));
|
||||
}
|
||||
|
||||
if (logger is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
if (pipeClient is null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(pipeClient));
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
WorkerOptionsParser parser = new(environment);
|
||||
WorkerBootstrapResult result = parser.Parse(args);
|
||||
|
||||
if (!result.Succeeded)
|
||||
{
|
||||
logger.Error("WorkerBootstrapFailed", new Dictionary<string, object?>
|
||||
{
|
||||
["exit_code"] = result.ExitCode,
|
||||
["errors"] = string.Join(";", result.Errors),
|
||||
});
|
||||
|
||||
return (int)result.ExitCode;
|
||||
}
|
||||
|
||||
WorkerOptions options = result.Options
|
||||
?? throw new InvalidOperationException("Successful bootstrap result did not include worker options.");
|
||||
|
||||
logger.Information("WorkerBootstrapSucceeded", new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = options.SessionId,
|
||||
["pipe_name"] = options.PipeName,
|
||||
["protocol_version"] = options.ProtocolVersion,
|
||||
["nonce"] = options.Nonce,
|
||||
});
|
||||
|
||||
pipeClient.RunAsync(options).GetAwaiter().GetResult();
|
||||
|
||||
logger.Information("WorkerPipeSessionCompleted", new Dictionary<string, object?>
|
||||
{
|
||||
["session_id"] = options.SessionId,
|
||||
["pipe_name"] = options.PipeName,
|
||||
["protocol_version"] = options.ProtocolVersion,
|
||||
});
|
||||
|
||||
return (int)WorkerExitCode.Success;
|
||||
}
|
||||
catch (WorkerFrameProtocolException exception)
|
||||
{
|
||||
logger.Error("WorkerPipeProtocolFailure", new Dictionary<string, object?>
|
||||
{
|
||||
["exit_code"] = WorkerExitCode.ProtocolViolation,
|
||||
["error_code"] = exception.ErrorCode,
|
||||
["exception_type"] = exception.GetType().FullName,
|
||||
});
|
||||
|
||||
return (int)WorkerExitCode.ProtocolViolation;
|
||||
}
|
||||
catch (Exception exception) when (exception is IOException or TimeoutException)
|
||||
{
|
||||
logger.Error("WorkerPipeConnectionFailed", new Dictionary<string, object?>
|
||||
{
|
||||
["exit_code"] = WorkerExitCode.PipeConnectionFailed,
|
||||
["exception_type"] = exception.GetType().FullName,
|
||||
});
|
||||
|
||||
return (int)WorkerExitCode.PipeConnectionFailed;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.Error("WorkerBootstrapUnexpectedFailure", new Dictionary<string, object?>
|
||||
{
|
||||
["exit_code"] = WorkerExitCode.UnexpectedFailure,
|
||||
["exception_type"] = exception.GetType().FullName,
|
||||
});
|
||||
|
||||
return (int)WorkerExitCode.UnexpectedFailure;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net48</TargetFramework>
|
||||
<PlatformTarget>x86</PlatformTarget>
|
||||
<Prefer32Bit>true</Prefer32Bit>
|
||||
<ImplicitUsings>disable</ImplicitUsings>
|
||||
<AutoGenerateBindingRedirects>true</AutoGenerateBindingRedirects>
|
||||
<GenerateBindingRedirectsOutputType>true</GenerateBindingRedirectsOutputType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Polly.Core" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="ZB.MOM.WW.MxGateway.Worker.Tests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="ArchestrA.MxAccess">
|
||||
<HintPath>C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll</HintPath>
|
||||
<Private>false</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
</Reference>
|
||||
<Reference Include="Interop.WNWRAPCONSUMERLib">
|
||||
<HintPath>..\..\lib\Interop.WNWRAPCONSUMERLib.dll</HintPath>
|
||||
<Private>true</Private>
|
||||
<SpecificVersion>false</SpecificVersion>
|
||||
<EmbedInteropTypes>false</EmbedInteropTypes>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user