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:
Joseph Doherty
2026-04-30 15:34:35 -04:00
parent ddad573b75
commit 0f88a953d7
11 changed files with 3159 additions and 154 deletions
@@ -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()));
}
}