feat(opcuaclient): add BuildBaseEventFilter + MapHistoryEvents pure cores
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user