proto: add alarm-transition event family + ack/query RPCs (PR A.1)
First PR of the alarms-over-gateway epic (docs/plans/alarms-over-gateway.md in lmxopcua). Pure contract-surface change — no functional wiring yet. Worker-side subscription (A.2), gateway-side dispatch + ack handler (A.3), and ConditionRefresh (A.4) follow. mxaccess_gateway.proto: - Extend MxEventFamily with MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5. - Extend MxEvent.body oneof with OnAlarmTransitionEvent on_alarm_transition = 24. - Add OnAlarmTransitionEvent message carrying the full MxAccess alarm payload (full reference, source object, alarm-type-name, transition kind, raw severity, original raise timestamp, transition timestamp, operator user/comment, category, description, current/limit value). Mapping to OPC UA 0-1000 severity ladder happens server-side in lmxopcua's MxAccessSeverityMapper (B.1) — gateway preserves the native MxAccess scale. - Add AlarmTransitionKind enum (Raise / Acknowledge / Clear / Retrigger). - Add ActiveAlarmSnapshot + AlarmConditionState for the ConditionRefresh stream. - Add public RPCs AcknowledgeAlarm (unary) and QueryActiveAlarms (server-streaming) on MxAccessGateway service. - Add AcknowledgeAlarmRequest/Reply + QueryActiveAlarmsRequest. GatewayContractInfo.GatewayProtocolVersion bumps 2 -> 3. Fixture manifests (proto-inputs, behavior, parity, golden OpenSessionReply) and protoset descriptor regenerated. Tests: round-trip serialization for the new messages with all-fields-populated and empty-optional-fields cases; oneof last-write-wins guard between OnDataChange and OnAlarmTransition; descriptor service-method enumeration includes the two new RPCs. All 273 existing tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user