proto: alarm-transition family + ack/query RPCs (PR A.1) #104

Merged
dohertj2 merged 1 commits from track-a1-alarm-proto into main 2026-04-30 15:37:10 -04:00
11 changed files with 3159 additions and 154 deletions
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"fixtureSet": "mxaccess-gateway-client-behavior",
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 2,
"gatewayProtocolVersion": 3,
"workerProtocolVersion": 1,
"protoInputManifest": "clients/proto/proto-inputs.json",
"fixtures": [
@@ -3,12 +3,14 @@
"backendName": "mxaccess-worker",
"workerProcessId": 1234,
"workerProtocolVersion": 1,
"gatewayProtocolVersion": 2,
"gatewayProtocolVersion": 3,
"capabilities": [
"unary-open-session",
"unary-close-session",
"unary-invoke",
"server-stream-events"
"server-stream-events",
"unary-acknowledge-alarm",
"server-stream-active-alarms"
],
"defaultCommandTimeout": "30s",
"protocolStatus": {
@@ -2,7 +2,7 @@
"schemaVersion": 1,
"fixtureSet": "mxaccess-gateway-parity-fixture-matrix",
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 2,
"gatewayProtocolVersion": 3,
"workerProtocolVersion": 1,
"sourceCaptureRoot": "C:/Users/dohertj2/Desktop/mxaccess/captures",
"sourceDocs": [
+1 -1
View File
@@ -1,7 +1,7 @@
{
"schemaVersion": 1,
"contractName": "mxaccess-gateway",
"gatewayProtocolVersion": 2,
"gatewayProtocolVersion": 3,
"workerProtocolVersion": 1,
"protoRoot": "src/MxGateway.Contracts/Protos",
"sourceFiles": [
@@ -6,7 +6,7 @@ namespace MxGateway.Contracts;
/// </summary>
public static class GatewayContractInfo
{
public const uint GatewayProtocolVersion = 2;
public const uint GatewayProtocolVersion = 3;
public const uint WorkerProtocolVersion = 1;
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,14 @@ namespace MxGateway.Contracts.Proto {
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.StreamEventsRequest> __Marshaller_mxaccess_gateway_v1_StreamEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.StreamEventsRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.MxEvent> __Marshaller_mxaccess_gateway_v1_MxEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.MxEvent.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest> __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> __Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest> __Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> __Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply> __Method_OpenSession = new grpc::Method<global::MxGateway.Contracts.Proto.OpenSessionRequest, global::MxGateway.Contracts.Proto.OpenSessionReply>(
@@ -97,6 +105,22 @@ namespace MxGateway.Contracts.Proto {
__Marshaller_mxaccess_gateway_v1_StreamEventsRequest,
__Marshaller_mxaccess_gateway_v1_MxEvent);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> __Method_AcknowledgeAlarm = new grpc::Method<global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply>(
grpc::MethodType.Unary,
__ServiceName,
"AcknowledgeAlarm",
__Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmRequest,
__Marshaller_mxaccess_gateway_v1_AcknowledgeAlarmReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> __Method_QueryActiveAlarms = new grpc::Method<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot>(
grpc::MethodType.ServerStreaming,
__ServiceName,
"QueryActiveAlarms",
__Marshaller_mxaccess_gateway_v1_QueryActiveAlarmsRequest,
__Marshaller_mxaccess_gateway_v1_ActiveAlarmSnapshot);
/// <summary>Service descriptor</summary>
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
@@ -131,6 +155,18 @@ namespace MxGateway.Contracts.Proto {
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> AcknowledgeAlarm(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::IServerStreamWriter<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> responseStream, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
}
/// <summary>Client for MxAccessGateway</summary>
@@ -230,6 +266,36 @@ namespace MxGateway.Contracts.Proto {
{
return CallInvoker.AsyncServerStreamingCall(__Method_StreamEvents, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply AcknowledgeAlarm(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return AcknowledgeAlarm(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply AcknowledgeAlarm(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_AcknowledgeAlarm, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> AcknowledgeAlarmAsync(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return AcknowledgeAlarmAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply> AcknowledgeAlarmAsync(global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_AcknowledgeAlarm, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return QueryActiveAlarms(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot> QueryActiveAlarms(global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncServerStreamingCall(__Method_QueryActiveAlarms, null, options, request);
}
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected override MxAccessGatewayClient NewInstance(ClientBaseConfiguration configuration)
@@ -247,7 +313,9 @@ namespace MxGateway.Contracts.Proto {
.AddMethod(__Method_OpenSession, serviceImpl.OpenSession)
.AddMethod(__Method_CloseSession, serviceImpl.CloseSession)
.AddMethod(__Method_Invoke, serviceImpl.Invoke)
.AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents).Build();
.AddMethod(__Method_StreamEvents, serviceImpl.StreamEvents)
.AddMethod(__Method_AcknowledgeAlarm, serviceImpl.AcknowledgeAlarm)
.AddMethod(__Method_QueryActiveAlarms, serviceImpl.QueryActiveAlarms).Build();
}
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
@@ -261,6 +329,8 @@ namespace MxGateway.Contracts.Proto {
serviceBinder.AddMethod(__Method_CloseSession, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.CloseSessionRequest, global::MxGateway.Contracts.Proto.CloseSessionReply>(serviceImpl.CloseSession));
serviceBinder.AddMethod(__Method_Invoke, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.MxCommandRequest, global::MxGateway.Contracts.Proto.MxCommandReply>(serviceImpl.Invoke));
serviceBinder.AddMethod(__Method_StreamEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.StreamEventsRequest, global::MxGateway.Contracts.Proto.MxEvent>(serviceImpl.StreamEvents));
serviceBinder.AddMethod(__Method_AcknowledgeAlarm, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.AcknowledgeAlarmRequest, global::MxGateway.Contracts.Proto.AcknowledgeAlarmReply>(serviceImpl.AcknowledgeAlarm));
serviceBinder.AddMethod(__Method_QueryActiveAlarms, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.QueryActiveAlarmsRequest, global::MxGateway.Contracts.Proto.ActiveAlarmSnapshot>(serviceImpl.QueryActiveAlarms));
}
}
@@ -13,6 +13,8 @@ service MxAccessGateway {
rpc CloseSession(CloseSessionRequest) returns (CloseSessionReply);
rpc Invoke(MxCommandRequest) returns (MxCommandReply);
rpc StreamEvents(StreamEventsRequest) returns (stream MxEvent);
rpc AcknowledgeAlarm(AcknowledgeAlarmRequest) returns (AcknowledgeAlarmReply);
rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream ActiveAlarmSnapshot);
}
message OpenSessionRequest {
@@ -397,6 +399,7 @@ message MxEvent {
OnWriteCompleteEvent on_write_complete = 21;
OperationCompleteEvent operation_complete = 22;
OnBufferedDataChangeEvent on_buffered_data_change = 23;
OnAlarmTransitionEvent on_alarm_transition = 24;
}
}
@@ -406,6 +409,7 @@ enum MxEventFamily {
MX_EVENT_FAMILY_ON_WRITE_COMPLETE = 2;
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
}
message OnDataChangeEvent {
@@ -424,6 +428,134 @@ message OnBufferedDataChangeEvent {
int32 raw_data_type = 4;
}
// Carries a single MXAccess alarm transition (raise / acknowledge / clear /
// re-trigger) in native MXAccess terms. The Part 9 state machine + ACL +
// multi-source aggregation lives in lmxopcua's AlarmConditionService; the
// gateway is UA-agnostic and forwards the raw payload.
message OnAlarmTransitionEvent {
// Fully-qualified alarm reference (e.g. "Tank01.Level.HiHi"). Stable across
// transitions of the same condition; used by the lmxopcua side to correlate
// raise/ack/clear into a single Part 9 condition.
string alarm_full_reference = 1;
// Galaxy-side source object reference (e.g. "Tank01"). Empty for alarms
// that do not bind to a Galaxy object.
string source_object_reference = 2;
// MxAccess alarm-type qualifier (e.g. "AnalogLimitAlarm.HiHi", "DiscAlarm").
string alarm_type_name = 3;
// What kind of state change this event represents.
AlarmTransitionKind transition_kind = 4;
// Raw MXAccess severity value. Mapping to OPC UA 0-1000 happens server-side
// in lmxopcua via MxAccessSeverityMapper; the gateway preserves the native
// MXAccess scale.
int32 severity = 5;
// When the alarm originally entered the active state. Preserved across
// acknowledge transitions so the Part 9 condition keeps the original raise
// time. Unset on retrigger from a previously-cleared condition.
google.protobuf.Timestamp original_raise_timestamp = 6;
// When this specific transition occurred (raise time on Raise, ack time on
// Acknowledge, clear time on Clear).
google.protobuf.Timestamp transition_timestamp = 7;
// Operator principal recorded by MXAccess on Acknowledge transitions.
// Empty on raise / clear.
string operator_user = 8;
// Operator-supplied comment recorded by MXAccess on Acknowledge transitions.
// Empty on raise / clear or when no comment was supplied.
string operator_comment = 9;
// MxAccess alarm category (taxonomy bucket configured in the Galaxy
// template, e.g. "Process", "Safety", "Diagnostics").
string category = 10;
// Human-readable alarm description from the MxAccess alarm definition.
string description = 11;
// Current alarm value (the value of the source attribute at the moment of
// transition). Optional; populated when MxAccess surfaces it.
MxValue current_value = 12;
// Limit/threshold value that triggered the transition for limit alarms.
// Optional; populated for AnalogLimitAlarm-family transitions.
MxValue limit_value = 13;
}
enum AlarmTransitionKind {
ALARM_TRANSITION_KIND_UNSPECIFIED = 0;
ALARM_TRANSITION_KIND_RAISE = 1;
ALARM_TRANSITION_KIND_ACKNOWLEDGE = 2;
ALARM_TRANSITION_KIND_CLEAR = 3;
ALARM_TRANSITION_KIND_RETRIGGER = 4;
}
// Snapshot of a currently-active MXAccess alarm condition, returned from a
// QueryActiveAlarms ConditionRefresh stream.
message ActiveAlarmSnapshot {
string alarm_full_reference = 1;
string source_object_reference = 2;
string alarm_type_name = 3;
int32 severity = 4;
google.protobuf.Timestamp original_raise_timestamp = 5;
AlarmConditionState current_state = 6;
string category = 7;
string description = 8;
// When the most recent state transition occurred (last raise, last ack,
// last clear).
google.protobuf.Timestamp last_transition_timestamp = 9;
// Operator who acknowledged the alarm if the current state is ActiveAcked.
// Empty otherwise.
string operator_user = 10;
// Operator comment recorded with the most recent acknowledge if the current
// state is ActiveAcked. Empty otherwise.
string operator_comment = 11;
MxValue current_value = 12;
MxValue limit_value = 13;
}
enum AlarmConditionState {
ALARM_CONDITION_STATE_UNSPECIFIED = 0;
ALARM_CONDITION_STATE_ACTIVE = 1;
ALARM_CONDITION_STATE_ACTIVE_ACKED = 2;
ALARM_CONDITION_STATE_INACTIVE = 3;
}
message AcknowledgeAlarmRequest {
string session_id = 1;
string client_correlation_id = 2;
// Fully-qualified alarm reference matching OnAlarmTransitionEvent.alarm_full_reference.
string alarm_full_reference = 3;
// Operator-supplied comment forwarded to MXAccess.
string comment = 4;
// Operator principal performing the acknowledgement. The lmxopcua side
// resolves this from the OPC UA session prior to invoking the RPC.
string operator_user = 5;
}
message AcknowledgeAlarmReply {
string session_id = 1;
string correlation_id = 2;
ProtocolStatus protocol_status = 3;
// HRESULT captured from MXAccess if the ack failed at the COM layer.
optional int32 hresult = 4;
// Native MxAccess status describing the outcome of the ack.
MxStatusProxy status = 5;
string diagnostic_message = 6;
}
message QueryActiveAlarmsRequest {
string session_id = 1;
string client_correlation_id = 2;
// Optional alarm-reference prefix used to scope a partial ConditionRefresh
// (e.g. equipment sub-tree). Empty means full refresh.
string alarm_filter_prefix = 3;
}
message MxStatusProxy {
int32 success = 1;
MxStatusCategory category = 2;
@@ -11,11 +11,11 @@ public sealed class GatewayContractInfoTests
Assert.Equal("mxaccess-worker", GatewayContractInfo.DefaultBackendName);
}
/// <summary>Verifies that the gateway protocol version starts at version one.</summary>
/// <summary>Verifies that the gateway protocol version is bumped to three after the alarm proto extension.</summary>
[Fact]
public void GatewayProtocolVersion_IsVersionTwo()
public void GatewayProtocolVersion_IsVersionThree()
{
Assert.Equal(2u, GatewayContractInfo.GatewayProtocolVersion);
Assert.Equal(3u, GatewayContractInfo.GatewayProtocolVersion);
}
/// <summary>Verifies that the worker protocol version starts at version one.</summary>
@@ -19,6 +19,8 @@ public sealed class ProtobufContractRoundTripTests
Assert.Contains(service.Methods, method => method.Name == "CloseSession");
Assert.Contains(service.Methods, method => method.Name == "Invoke");
Assert.Contains(service.Methods, method => method.Name == "StreamEvents");
Assert.Contains(service.Methods, method => method.Name == "AcknowledgeAlarm");
Assert.Contains(service.Methods, method => method.Name == "QueryActiveAlarms");
}
/// <summary>Verifies that worker envelope descriptor contains required correlation fields.</summary>
@@ -198,4 +200,193 @@ public sealed class ProtobufContractRoundTripTests
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, parsed.BodyCase);
Assert.Equal(MxCommand.PayloadOneofCase.Advise, parsed.WorkerCommand.Command.PayloadCase);
}
/// <summary>Verifies that an OnAlarmTransition event round-trips with full payload.</summary>
[Fact]
public void Event_RoundTripsOnAlarmTransitionWithFullPayload()
{
var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc));
var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc));
var original = new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
SessionId = "session-1",
WorkerSequence = 99,
WorkerTimestamp = ack,
GatewayReceiveTimestamp = ack,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Acknowledge,
Severity = 750,
OriginalRaiseTimestamp = raise,
TransitionTimestamp = ack,
OperatorUser = "operator1",
OperatorComment = "investigating",
Category = "Process",
Description = "Tank 01 high-high level",
CurrentValue = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 95.4f,
VariantType = "VT_R4",
},
LimitValue = new MxValue
{
DataType = MxDataType.Float,
FloatValue = 90.0f,
VariantType = "VT_R4",
},
},
};
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, parsed.BodyCase);
Assert.Equal(AlarmTransitionKind.Acknowledge, parsed.OnAlarmTransition.TransitionKind);
Assert.Equal(raise, parsed.OnAlarmTransition.OriginalRaiseTimestamp);
Assert.Equal("operator1", parsed.OnAlarmTransition.OperatorUser);
}
/// <summary>Verifies that an OnAlarmTransition event round-trips with only the required fields populated.</summary>
[Fact]
public void Event_RoundTripsOnAlarmTransitionWithOptionalFieldsEmpty()
{
var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc));
var original = new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
SessionId = "session-1",
WorkerSequence = 100,
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "Tank01.Level.HiHi",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
TransitionKind = AlarmTransitionKind.Raise,
Severity = 750,
TransitionTimestamp = raise,
},
};
var parsed = MxEvent.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorUser);
Assert.Equal(string.Empty, parsed.OnAlarmTransition.OperatorComment);
Assert.Null(parsed.OnAlarmTransition.OriginalRaiseTimestamp);
Assert.Null(parsed.OnAlarmTransition.CurrentValue);
}
/// <summary>Verifies that an MxEvent body oneof rejects multiple bodies — last write wins per proto3 semantics.</summary>
[Fact]
public void Event_OneofGuard_LastBodyWins()
{
var ev = new MxEvent
{
Family = MxEventFamily.OnAlarmTransition,
OnDataChange = new OnDataChangeEvent(),
OnAlarmTransition = new OnAlarmTransitionEvent
{
AlarmFullReference = "X",
TransitionKind = AlarmTransitionKind.Raise,
},
};
Assert.Equal(MxEvent.BodyOneofCase.OnAlarmTransition, ev.BodyCase);
Assert.Null(ev.OnDataChange);
}
/// <summary>Verifies that AcknowledgeAlarmRequest round-trips through serialization.</summary>
[Fact]
public void AcknowledgeAlarmRequest_RoundTripsAllFields()
{
var original = new AcknowledgeAlarmRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-7",
AlarmFullReference = "Tank01.Level.HiHi",
Comment = "shift handover",
OperatorUser = "operator2",
};
var parsed = AcknowledgeAlarmRequest.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
}
/// <summary>Verifies that AcknowledgeAlarmReply round-trips with status, hresult, and diagnostics.</summary>
[Fact]
public void AcknowledgeAlarmReply_RoundTripsStatusAndHresult()
{
var original = new AcknowledgeAlarmReply
{
SessionId = "session-1",
CorrelationId = "gateway-correlation-7",
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Hresult = 0,
Status = new MxStatusProxy
{
Success = 1,
Category = MxStatusCategory.Ok,
DetectedBy = MxStatusSource.RespondingLmx,
},
DiagnosticMessage = "ack accepted",
};
var parsed = AcknowledgeAlarmReply.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.True(parsed.HasHresult);
}
/// <summary>Verifies that ActiveAlarmSnapshot round-trips with current state and operator metadata.</summary>
[Fact]
public void ActiveAlarmSnapshot_RoundTripsAllFields()
{
var raise = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 0, DateTimeKind.Utc));
var ack = Timestamp.FromDateTime(new DateTime(2026, 5, 1, 12, 0, 30, DateTimeKind.Utc));
var original = new ActiveAlarmSnapshot
{
AlarmFullReference = "Tank01.Level.HiHi",
SourceObjectReference = "Tank01",
AlarmTypeName = "AnalogLimitAlarm.HiHi",
Severity = 750,
OriginalRaiseTimestamp = raise,
CurrentState = AlarmConditionState.ActiveAcked,
Category = "Process",
Description = "Tank 01 high-high level",
LastTransitionTimestamp = ack,
OperatorUser = "operator2",
OperatorComment = "investigating",
};
var parsed = ActiveAlarmSnapshot.Parser.ParseFrom(original.ToByteArray());
Assert.Equal(original, parsed);
Assert.Equal(AlarmConditionState.ActiveAcked, parsed.CurrentState);
}
/// <summary>Verifies that QueryActiveAlarmsRequest round-trips empty filter prefix.</summary>
[Fact]
public void QueryActiveAlarmsRequest_RoundTripsWithAndWithoutFilter()
{
var withoutFilter = new QueryActiveAlarmsRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-8",
};
var withFilter = new QueryActiveAlarmsRequest
{
SessionId = "session-1",
ClientCorrelationId = "client-correlation-9",
AlarmFilterPrefix = "Tank01.",
};
Assert.Equal(withoutFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withoutFilter.ToByteArray()));
Assert.Equal(withFilter, QueryActiveAlarmsRequest.Parser.ParseFrom(withFilter.ToByteArray()));
}
}