feat(opcuaclient): add BuildBaseEventFilter + MapHistoryEvents pure cores

This commit is contained in:
Joseph Doherty
2026-06-18 06:02:11 -04:00
parent 767bc56d97
commit e859963853
2 changed files with 156 additions and 0 deletions
@@ -1675,6 +1675,91 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
_ => throw new ArgumentOutOfRangeException(nameof(aggregate), aggregate, null),
};
// Canonical BaseEventType select-clause order — MapHistoryEvents maps by these indices.
private static readonly string[] EventFieldBrowseNames =
[
BrowseNames.EventId, // 0
BrowseNames.SourceName, // 1
BrowseNames.Time, // 2
BrowseNames.ReceiveTime, // 3
BrowseNames.Message, // 4
BrowseNames.Severity, // 5
];
/// <summary>
/// Builds the fixed canonical EventFilter the driver sends upstream for HistoryReadEvents —
/// the six BaseEventType fields the OtOpcUa server projects (<see cref="HistoricalEvent"/>).
/// The clause order is load-bearing: <see cref="MapHistoryEvents"/> reads results by index.
/// </summary>
/// <returns>An EventFilter with six SimpleAttributeOperand value clauses.</returns>
internal static EventFilter BuildBaseEventFilter()
{
var filter = new EventFilter();
foreach (var browseName in EventFieldBrowseNames)
{
filter.SelectClauses.Add(new SimpleAttributeOperand
{
TypeDefinitionId = ObjectTypeIds.BaseEventType,
BrowsePath = new QualifiedNameCollection { new QualifiedName(browseName) },
AttributeId = Attributes.Value,
});
}
return filter;
}
/// <summary>
/// Maps an upstream <see cref="HistoryEvent"/> (field arrays ordered to match
/// <see cref="BuildBaseEventFilter"/>) onto <see cref="HistoricalEvent"/> records. Defensive:
/// short / null / wrong-typed fields degrade to null/default rather than throwing.
/// </summary>
/// <param name="historyEvent">The upstream history-event payload.</param>
/// <returns>The mapped historical events in upstream order.</returns>
internal static IReadOnlyList<Core.Abstractions.HistoricalEvent> MapHistoryEvents(HistoryEvent historyEvent)
{
if (historyEvent?.Events is not { Count: > 0 } rows) return [];
var result = new List<Core.Abstractions.HistoricalEvent>(rows.Count);
foreach (var row in rows)
{
var fields = row?.EventFields;
result.Add(new Core.Abstractions.HistoricalEvent(
EventId: CoerceEventId(FieldAt(fields, 0)),
SourceName: CoerceString(FieldAt(fields, 1)),
EventTimeUtc: CoerceDateTime(FieldAt(fields, 2)),
ReceivedTimeUtc: CoerceDateTime(FieldAt(fields, 3)),
Message: CoerceString(FieldAt(fields, 4)),
Severity: CoerceSeverity(FieldAt(fields, 5))));
}
return result;
}
private static object? FieldAt(VariantCollection? fields, int index)
=> fields is not null && index < fields.Count ? fields[index].Value : null;
private static string CoerceEventId(object? value) => value switch
{
byte[] bytes => Convert.ToBase64String(bytes),
string s => s,
null => string.Empty,
_ => value.ToString() ?? string.Empty,
};
private static string? CoerceString(object? value) => value switch
{
LocalizedText lt => lt.Text,
string s => s,
null => null,
_ => value.ToString(),
};
private static DateTime CoerceDateTime(object? value)
=> value is DateTime dt ? dt : DateTime.MinValue;
private static ushort CoerceSeverity(object? value)
{
try { return value is null ? (ushort)0 : Convert.ToUInt16(value); }
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { return 0; }
}
// ReadEventsAsync stays at the interface default (throws NotSupportedException) per
// IHistoryProvider contract -- the OPC UA Client driver CAN forward HistoryReadEvents,
// but the call-site needs an EventFilter SelectClauses surface which the interface
@@ -96,4 +96,75 @@ public sealed class OpcUaClientHistoryTests
maxEvents: 100,
cancellationToken: TestContext.Current.CancellationToken));
}
/// <summary>BuildBaseEventFilter emits the six canonical BaseEventType select clauses in order.</summary>
[Fact]
public void BuildBaseEventFilter_has_six_canonical_BaseEventType_value_clauses()
{
var filter = OpcUaClientDriver.BuildBaseEventFilter();
filter.SelectClauses.Count.ShouldBe(6);
string[] expected = ["EventId", "SourceName", "Time", "ReceiveTime", "Message", "Severity"];
for (var i = 0; i < expected.Length; i++)
{
var clause = filter.SelectClauses[i];
clause.TypeDefinitionId.ShouldBe(ObjectTypeIds.BaseEventType);
clause.AttributeId.ShouldBe(Attributes.Value);
clause.BrowsePath.Count.ShouldBe(1);
clause.BrowsePath[0].Name.ShouldBe(expected[i]);
}
}
/// <summary>MapHistoryEvents maps every field by its canonical select-clause index.</summary>
[Fact]
public void MapHistoryEvents_maps_all_six_fields_by_canonical_index()
{
var eventTime = new DateTime(2026, 6, 18, 10, 0, 0, DateTimeKind.Utc);
var recvTime = eventTime.AddSeconds(1);
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 1, 2, 3 })); // 0 EventId
fields.EventFields.Add(new Variant("Pump17")); // 1 SourceName
fields.EventFields.Add(new Variant(eventTime)); // 2 Time
fields.EventFields.Add(new Variant(recvTime)); // 3 ReceiveTime
fields.EventFields.Add(new Variant(new LocalizedText("High temp"))); // 4 Message
fields.EventFields.Add(new Variant((ushort)700)); // 5 Severity
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
var e = mapped[0];
e.EventId.ShouldBe(Convert.ToBase64String(new byte[] { 1, 2, 3 }));
e.SourceName.ShouldBe("Pump17");
e.EventTimeUtc.ShouldBe(eventTime);
e.ReceivedTimeUtc.ShouldBe(recvTime);
e.Message.ShouldBe("High temp");
e.Severity.ShouldBe((ushort)700);
}
/// <summary>MapHistoryEvents returns empty for a HistoryEvent with no rows.</summary>
[Fact]
public void MapHistoryEvents_with_no_events_returns_empty()
{
OpcUaClientDriver.MapHistoryEvents(new HistoryEvent()).ShouldBeEmpty();
}
/// <summary>MapHistoryEvents tolerates a field list shorter than six without throwing.</summary>
[Fact]
public void MapHistoryEvents_tolerates_short_field_list_without_throwing()
{
var he = new HistoryEvent();
var fields = new HistoryEventFieldList();
fields.EventFields.Add(new Variant(new byte[] { 9 })); // only EventId present
he.Events.Add(fields);
var mapped = OpcUaClientDriver.MapHistoryEvents(he);
mapped.Count.ShouldBe(1);
mapped[0].EventId.ShouldBe(Convert.ToBase64String(new byte[] { 9 }));
mapped[0].SourceName.ShouldBeNull();
mapped[0].EventTimeUtc.ShouldBe(DateTime.MinValue);
mapped[0].Severity.ShouldBe((ushort)0);
}
}