docs(client): document Galaxy fallback operator-comment limitation + pin null behavior
Closes backlog #12: Galaxy's AckMsg attribute is WRITE-ONLY and the OPC UA event SelectClause carries no comment field, making OperatorComment unrecoverable on the sub-attribute fallback path. Documents the two concrete reasons in-code and tightens the AlarmEventArgs XML doc; adds pinning test OnAlarmEvent_GalaxyFallback_LeavesOperatorCommentNull.
This commit is contained in:
@@ -81,9 +81,13 @@ public sealed class AlarmEventArgs : EventArgs
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// PR E.7 — Operator-supplied comment recorded by the upstream alarm system on
|
/// PR E.7 — Operator-supplied comment recorded by the upstream alarm system on
|
||||||
/// Acknowledge transitions. Null on raise / clear, or when the upstream path
|
/// Acknowledge transitions. Null on raise / clear events, and intentionally null
|
||||||
/// can't surface the comment (sub-attribute fallback path collapses comments
|
/// on the Galaxy OPC UA sub-attribute fallback path for two unrecoverable reasons:
|
||||||
/// into a single string write).
|
/// (a) Galaxy's <c>AckMsg</c> attribute is WRITE-ONLY — no readable attribute
|
||||||
|
/// exposes the last operator comment — and (b) the OPC UA event SelectClause
|
||||||
|
/// carries no comment field. The operator comment is only available via the
|
||||||
|
/// gateway's native alarm feed (<c>GalaxyAlarmTransition.OperatorComment</c>
|
||||||
|
/// in the Galaxy driver path).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string? OperatorComment { get; }
|
public string? OperatorComment { get; }
|
||||||
|
|
||||||
|
|||||||
@@ -814,6 +814,15 @@ public sealed class OpcUaClientService : IOpcUaClientService
|
|||||||
// Other supplemental read failure; deliver event with defaults
|
// Other supplemental read failure; deliver event with defaults
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OperatorComment is intentionally left null (default) here.
|
||||||
|
// Two concrete reasons make it unrecoverable in this Galaxy sub-attribute fallback path:
|
||||||
|
// (a) Galaxy's AckMsg attribute is WRITE-ONLY — there is no readable attribute that
|
||||||
|
// exposes the last operator comment, so no supplemental read can recover it.
|
||||||
|
// (b) The OPC UA event SelectClause (CreateAlarmEventFilter) carries no comment field;
|
||||||
|
// the OPC UA event notification itself never delivers a comment string.
|
||||||
|
// The operator comment is only available via the gateway's native alarm feed
|
||||||
|
// (the Galaxy driver path, GalaxyAlarmTransition.OperatorComment), not this
|
||||||
|
// OpcUaClient sub-attribute fallback.
|
||||||
AlarmEvent?.Invoke(this, new AlarmEventArgs(
|
AlarmEvent?.Invoke(this, new AlarmEventArgs(
|
||||||
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
|
sourceName, conditionName, severity, capturedMessage, retain, activeState, ackedState, time,
|
||||||
eventId, capturedConditionNodeId));
|
eventId, capturedConditionNodeId));
|
||||||
|
|||||||
@@ -1251,6 +1251,82 @@ public class OpcUaClientServiceTests : IDisposable
|
|||||||
received.Message.ShouldBe("Fallback message"); // from DescAttrName read
|
received.Message.ShouldBe("Fallback message"); // from DescAttrName read
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pins the accepted limitation that OperatorComment is always null on the Galaxy
|
||||||
|
/// sub-attribute fallback path. Two concrete reasons make it unrecoverable:
|
||||||
|
/// (a) Galaxy's AckMsg attribute is WRITE-ONLY — no readable attribute exposes the
|
||||||
|
/// last operator comment — and (b) the OPC UA event SelectClause carries no comment
|
||||||
|
/// field. The operator comment is only available via the gateway's native alarm feed
|
||||||
|
/// (GalaxyAlarmTransition.OperatorComment in the Galaxy driver path, not here).
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task OnAlarmEvent_GalaxyFallback_LeavesOperatorCommentNull()
|
||||||
|
{
|
||||||
|
var fakeSub = new FakeSubscriptionAdapter();
|
||||||
|
var session = new FakeSessionAdapter
|
||||||
|
{
|
||||||
|
NextSubscription = fakeSub,
|
||||||
|
ReadResponseFunc = nodeId =>
|
||||||
|
{
|
||||||
|
var key = nodeId.ToString();
|
||||||
|
if (key.EndsWith(".InAlarm"))
|
||||||
|
return new DataValue(new Variant(true), StatusCodes.Good);
|
||||||
|
if (key.EndsWith(".Acked"))
|
||||||
|
return new DataValue(new Variant(true), StatusCodes.Good);
|
||||||
|
if (key.EndsWith(".TimeAlarmOn"))
|
||||||
|
return new DataValue(new Variant(new DateTime(2026, 1, 1, 12, 0, 0)), StatusCodes.Good);
|
||||||
|
if (key.EndsWith(".DescAttrName"))
|
||||||
|
return new DataValue(new Variant("Ack message"), StatusCodes.Good);
|
||||||
|
return new DataValue(StatusCodes.BadNodeIdUnknown);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
_sessionFactory.EnqueueSession(session);
|
||||||
|
await _service.ConnectAsync(ValidSettings());
|
||||||
|
|
||||||
|
AlarmEventArgs? received = null;
|
||||||
|
var raised = new TaskCompletionSource();
|
||||||
|
_service.AlarmEvent += (_, e) =>
|
||||||
|
{
|
||||||
|
received = e;
|
||||||
|
raised.TrySetResult();
|
||||||
|
};
|
||||||
|
|
||||||
|
await _service.SubscribeAlarmsAsync();
|
||||||
|
|
||||||
|
var handle = fakeSub.ActiveHandles.First();
|
||||||
|
// AckedState/Id (8) and ActiveState/Id (9) are null, triggering the Galaxy
|
||||||
|
// sub-attribute fallback; SourceNode (12) is set so conditionNodeId is populated.
|
||||||
|
var fields = new EventFieldList
|
||||||
|
{
|
||||||
|
EventFields =
|
||||||
|
[
|
||||||
|
new Variant(new byte[] { 4, 5, 6 }), // 0: EventId
|
||||||
|
new Variant(ObjectTypeIds.AlarmConditionType), // 1: EventType
|
||||||
|
new Variant("Source2"), // 2: SourceName
|
||||||
|
new Variant(DateTime.MinValue), // 3: Time
|
||||||
|
new Variant(new LocalizedText("Ack")), // 4: Message
|
||||||
|
new Variant((ushort)200), // 5: Severity
|
||||||
|
new Variant("CondName2"), // 6: ConditionName
|
||||||
|
new Variant(true), // 7: Retain
|
||||||
|
Variant.Null, // 8: AckedState/Id — missing
|
||||||
|
Variant.Null, // 9: ActiveState/Id — missing
|
||||||
|
new Variant(true), // 10: EnabledState/Id
|
||||||
|
new Variant(false), // 11: SuppressedOrShelved
|
||||||
|
new Variant("ns=2;s=ConditionId2") // 12: SourceNode
|
||||||
|
]
|
||||||
|
};
|
||||||
|
fakeSub.SimulateEvent(handle, fields);
|
||||||
|
|
||||||
|
// The fallback runs on a background Task.Run continuation — wait briefly for it.
|
||||||
|
await Task.WhenAny(raised.Task, Task.Delay(500));
|
||||||
|
|
||||||
|
received.ShouldNotBeNull();
|
||||||
|
// OperatorComment must be null: AckMsg is WRITE-ONLY in Galaxy and the OPC UA
|
||||||
|
// event SelectClause carries no comment field — both reasons are unrecoverable
|
||||||
|
// on this client sub-attribute fallback path.
|
||||||
|
received!.OperatorComment.ShouldBeNull();
|
||||||
|
}
|
||||||
|
|
||||||
// --- Failover tests ---
|
// --- Failover tests ---
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
Reference in New Issue
Block a user