clients/dotnet: SDK methods for AcknowledgeAlarm + QueryActiveAlarms (PR E.2)

Seventh PR of the alarms-over-gateway epic
(docs/plans/alarms-over-gateway.md). Depends on PR A.1 (proto, merged)
and E.1 (regen, merged).

Hand-written .NET SDK methods on top of the regenerated proto types:

- MxGatewayClient.AcknowledgeAlarmAsync — routes through the existing
  safe-unary retry pipeline (Acks are idempotent at MxAccess), maps
  Unauthenticated/PermissionDenied RpcExceptions to typed
  MxGatewayAuthenticationException / MxGatewayAuthorizationException
  via GrpcMxGatewayClientTransport.MapRpcException.
- MxGatewayClient.QueryActiveAlarmsAsync — server-streaming
  IAsyncEnumerable<ActiveAlarmSnapshot> mirroring the StreamEvents
  pattern.
- IMxGatewayClientTransport extended; GrpcMxGatewayClientTransport
  implements both methods using the regenerated grpc client.
- FakeGatewayTransport extended with capture lists, exception queue,
  and reply / snapshot enqueue helpers.

CLI version-string assertions updated for the GatewayProtocolVersion
2 → 3 bump from A.1.

The CLI alarms verb (subscribe / acknowledge / query-active) is
deferred to a follow-up — keeping this PR focused on the SDK surface
that lmxopcua's GalaxyDriver consumes in PR B.2. The other-language
SDKs (E.3-E.6) layer the same shape on the regen.

Tests:
- 6 new MxGatewayClientAlarmsTests — request shape, cancellation
  honor (linked-token via retry pipeline), Unauthenticated mapping,
  streaming snapshot enumeration, filter prefix passthrough,
  cancellation during enumeration.
- Full client test suite: 57 passed (was 51; 6 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-30 16:37:12 -04:00
parent 26d0e2c471
commit 0765eb4de3
6 changed files with 387 additions and 2 deletions
@@ -41,6 +41,24 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
/// </summary>
public List<(StreamEventsRequest Request, CallOptions CallOptions)> StreamEventsCalls { get; } = [];
/// <summary>
/// Gets the list of captured AcknowledgeAlarmAsync calls.
/// </summary>
public List<(AcknowledgeAlarmRequest Request, CallOptions CallOptions)> AcknowledgeAlarmCalls { get; } = [];
/// <summary>
/// Gets the list of captured QueryActiveAlarmsAsync calls.
/// </summary>
public List<(QueryActiveAlarmsRequest Request, CallOptions CallOptions)> QueryActiveAlarmsCalls { get; } = [];
/// <summary>
/// Gets the queue of exceptions to throw from AcknowledgeAlarmAsync.
/// </summary>
public Queue<Exception> AcknowledgeAlarmExceptions { get; } = new();
private readonly Queue<AcknowledgeAlarmReply> _acknowledgeReplies = new();
private readonly List<ActiveAlarmSnapshot> _activeAlarmSnapshots = [];
/// <summary>
/// Gets or sets the reply to return from OpenSessionAsync.
/// </summary>
@@ -168,4 +186,57 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
{
_events.Add(gatewayEvent);
}
/// <summary>
/// Records the acknowledge call and returns the next enqueued reply (or default).
/// </summary>
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
AcknowledgeAlarmRequest request,
CallOptions callOptions)
{
AcknowledgeAlarmCalls.Add((request, callOptions));
if (AcknowledgeAlarmExceptions.TryDequeue(out Exception? exception))
{
throw exception;
}
return Task.FromResult(_acknowledgeReplies.Count > 0
? _acknowledgeReplies.Dequeue()
: new AcknowledgeAlarmReply
{
SessionId = request.SessionId,
CorrelationId = request.ClientCorrelationId,
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
Status = new MxStatusProxy { Success = 1, Category = MxStatusCategory.Ok },
});
}
/// <summary>
/// Records the query call and yields each enqueued snapshot.
/// </summary>
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
QueryActiveAlarmsRequest request,
CallOptions callOptions)
{
QueryActiveAlarmsCalls.Add((request, callOptions));
foreach (ActiveAlarmSnapshot snapshot in _activeAlarmSnapshots)
{
callOptions.CancellationToken.ThrowIfCancellationRequested();
await Task.Yield();
yield return snapshot;
}
}
/// <summary>Enqueues an acknowledge reply.</summary>
public void AddAcknowledgeReply(AcknowledgeAlarmReply reply)
{
_acknowledgeReplies.Enqueue(reply);
}
/// <summary>Enqueues a snapshot to be yielded from QueryActiveAlarmsAsync.</summary>
public void AddActiveAlarmSnapshot(ActiveAlarmSnapshot snapshot)
{
_activeAlarmSnapshots.Add(snapshot);
}
}