feat(historian): HistoryReadEvents over equipment-folder notifiers + event-field projection
This commit is contained in:
@@ -46,6 +46,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
/// Keyed by NodeId → the actual <see cref="FolderState"/> so <see cref="RebuildAddressSpace"/> can
|
||||||
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
/// pass the folder to <c>RemoveRootNotifier</c> on teardown.</summary>
|
||||||
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
private readonly Dictionary<NodeId, FolderState> _notifierFolders = new();
|
||||||
|
/// <summary>Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source
|
||||||
|
/// name passed to <see cref="IHistorianDataSource.ReadEventsAsync"/>. The equipment-folder NodeId
|
||||||
|
/// identifier IS the equipment id, which IS the sourceName, so key and value are the same string;
|
||||||
|
/// the map's presence (not its value) is what makes a folder an event-history source. Populated by
|
||||||
|
/// <see cref="EnsureFolderIsEventNotifier"/> only when a real historian is wired at promotion time,
|
||||||
|
/// and the <see cref="HistoryReadEvents"/> override resolves an inbound request's notifier NodeId
|
||||||
|
/// against it (a miss ⇒ <c>BadHistoryOperationUnsupported</c>). Cleared on
|
||||||
|
/// <see cref="RebuildAddressSpace"/>.</summary>
|
||||||
|
private readonly ConcurrentDictionary<string, string> _eventNotifierSources = new(StringComparer.Ordinal);
|
||||||
private FolderState? _root;
|
private FolderState? _root;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
/// <summary>Initializes a new instance of the <see cref="OtOpcUaNodeManager"/> class with the OPC UA server and configuration.</summary>
|
||||||
@@ -170,6 +179,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
internal BaseDataVariableState? TryGetVariable(string nodeId) =>
|
internal BaseDataVariableState? TryGetVariable(string nodeId) =>
|
||||||
_variables.TryGetValue(nodeId, out var variable) ? variable : null;
|
_variables.TryGetValue(nodeId, out var variable) ? variable : null;
|
||||||
|
|
||||||
|
/// <summary>Look up a materialised folder node by its NodeId string, or null if not present.
|
||||||
|
/// Exposed for tests so they can resolve an equipment folder's NodeId (e.g. the event-notifier
|
||||||
|
/// node a HistoryReadEvents request targets).</summary>
|
||||||
|
/// <param name="nodeId">The folder node identifier.</param>
|
||||||
|
/// <returns>The cached <see cref="FolderState"/>, or null when none is registered.</returns>
|
||||||
|
internal FolderState? TryGetFolder(string nodeId) =>
|
||||||
|
_folders.TryGetValue(nodeId, out var folder) ? folder : null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
/// Apply a value write from <see cref="IOpcUaAddressSpaceSink.WriteValue"/>. Creates the
|
||||||
/// variable node on first call; subsequent calls update Value + StatusCode +
|
/// variable node on first call; subsequent calls update Value + StatusCode +
|
||||||
@@ -803,10 +820,31 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
/// <summary>Promote <paramref name="folder"/> to <see cref="EventNotifiers.SubscribeToEvents"/> and
|
||||||
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
/// register it as a root notifier (idempotent — guarded by <see cref="_notifierFolders"/>) so the
|
||||||
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
|
/// alarm condition has a notifier path to the Server object for T16's event propagation.</summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the
|
||||||
|
/// <see cref="NullHistorianDataSource"/>), the folder ALSO gets the
|
||||||
|
/// <see cref="EventNotifiers.HistoryRead"/> bit OR-ed in (keeping SubscribeToEvents) and registers
|
||||||
|
/// its NodeId identifier as an event-history source so the <see cref="HistoryReadEvents"/> override
|
||||||
|
/// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at
|
||||||
|
/// the moment of promotion: the Host wires the source at <c>StartAsync</c> — BEFORE any deployment
|
||||||
|
/// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder
|
||||||
|
/// promoted while the source is still Null advertises live-event subscription but NOT event history
|
||||||
|
/// until the next <see cref="RebuildAddressSpace"/> re-promotes it (acceptable, documented). The
|
||||||
|
/// HistoryRead bit + source registration happen inside the same first-time
|
||||||
|
/// <see cref="_notifierFolders"/> block so the idempotency guard covers them too.
|
||||||
|
/// </remarks>
|
||||||
private void EnsureFolderIsEventNotifier(FolderState folder)
|
private void EnsureFolderIsEventNotifier(FolderState folder)
|
||||||
{
|
{
|
||||||
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
|
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
|
||||||
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
folder.EventNotifier = EventNotifiers.SubscribeToEvents;
|
||||||
|
if (_historianDataSource is not NullHistorianDataSource)
|
||||||
|
{
|
||||||
|
// A historian is wired: advertise event history on this notifier and register it as a source.
|
||||||
|
// The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName.
|
||||||
|
folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead);
|
||||||
|
var sourceName = folder.NodeId.Identifier?.ToString() ?? string.Empty;
|
||||||
|
_eventNotifierSources[sourceName] = sourceName;
|
||||||
|
}
|
||||||
AddRootNotifier(folder);
|
AddRootNotifier(folder);
|
||||||
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
folder.ClearChangeMasks(SystemContext, includeChildren: false);
|
||||||
}
|
}
|
||||||
@@ -998,6 +1036,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
|
// Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt)
|
||||||
// equipment folders to event notifiers.
|
// equipment folders to event notifiers.
|
||||||
_notifierFolders.Clear();
|
_notifierFolders.Clear();
|
||||||
|
// Phase C (Task 4): drop the event-history source registrations alongside the notifier folders
|
||||||
|
// they map; re-materialised alarms re-register them (with the HistoryRead bit) on re-promotion.
|
||||||
|
_eventNotifierSources.Clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1124,6 +1165,151 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Serve a HistoryRead-Events request over the equipment-folder event-notifier nodes (the folders
|
||||||
|
/// that own alarm conditions). Each handle's NodeId identifier is resolved against
|
||||||
|
/// <see cref="_eventNotifierSources"/>: a miss ⇒ <c>BadHistoryOperationUnsupported</c> (a node we own
|
||||||
|
/// that isn't a registered event-history source — e.g. a plain folder, or one promoted while no
|
||||||
|
/// historian was wired); a hit block-bridges to <see cref="IHistorianDataSource.ReadEventsAsync"/>
|
||||||
|
/// for the folder's source name and projects each <see cref="HistoricalEvent"/> into a
|
||||||
|
/// <see cref="HistoryEventFieldList"/> per the request's event filter. Like the Raw/Processed/AtTime
|
||||||
|
/// arms this is NOT invoked under the node-manager <c>Lock</c>, so the block-bridge is safe; each
|
||||||
|
/// handle is served under try/catch so a backend throw becomes a Bad status for THAT node only and
|
||||||
|
/// never throws out of the batch.
|
||||||
|
/// </summary>
|
||||||
|
protected override void HistoryReadEvents(
|
||||||
|
ServerSystemContext context,
|
||||||
|
ReadEventDetails details,
|
||||||
|
TimestampsToReturn timestampsToReturn,
|
||||||
|
IList<HistoryReadValueId> nodesToRead,
|
||||||
|
IList<SdkHistoryReadResult> results,
|
||||||
|
IList<ServiceResult> errors,
|
||||||
|
List<NodeHandle> nodesToProcess,
|
||||||
|
IDictionary<NodeId, NodeState> cache)
|
||||||
|
{
|
||||||
|
// Snapshot the select clauses once — the same filter projects every node's events.
|
||||||
|
var selectClauses = details.Filter?.SelectClauses ?? new SimpleAttributeOperandCollection();
|
||||||
|
|
||||||
|
foreach (var handle in nodesToProcess)
|
||||||
|
{
|
||||||
|
var idString = handle.NodeId.Identifier?.ToString();
|
||||||
|
if (idString is null || !_eventNotifierSources.TryGetValue(idString, out var sourceName))
|
||||||
|
{
|
||||||
|
// Not a registered event-history source (plain folder / Null-source promotion) ⇒ unsupported.
|
||||||
|
// (The base pre-seeds this same status; set it explicitly so the contract is local + obvious.)
|
||||||
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// NOT under the node-manager Lock — block-bridging the async source is safe here.
|
||||||
|
var sourceResult = _historianDataSource.ReadEventsAsync(
|
||||||
|
sourceName,
|
||||||
|
details.StartTime,
|
||||||
|
details.EndTime,
|
||||||
|
// NumValuesPerNode is uint; ReadEventsAsync takes int (<=0 ⇒ backend default cap).
|
||||||
|
ClampToInt(details.NumValuesPerNode),
|
||||||
|
CancellationToken.None).GetAwaiter().GetResult();
|
||||||
|
|
||||||
|
var historyEvent = ProjectEvents(sourceResult.Events, selectClauses);
|
||||||
|
results[handle.Index] = new SdkHistoryReadResult
|
||||||
|
{
|
||||||
|
// No events ⇒ GoodNoData (the notifier is historized, the window just held no events).
|
||||||
|
StatusCode = sourceResult.Events.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||||||
|
HistoryData = new ExtensionObject(historyEvent),
|
||||||
|
// We never issue continuation points — every read returns the full window in one shot.
|
||||||
|
ContinuationPoint = null,
|
||||||
|
};
|
||||||
|
errors[handle.Index] = ServiceResult.Good;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// One node's backend failure must not throw out of the batch — surface Bad for THIS node
|
||||||
|
// only. This manager carries no ILogger, so log via the SDK's static trace (see ServeNode).
|
||||||
|
#pragma warning disable CS0618 // Type or member is obsolete
|
||||||
|
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryReadEvents failed for node {0}", handle.NodeId);
|
||||||
|
#pragma warning restore CS0618
|
||||||
|
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Clamp a <see cref="uint"/> request cap to a non-negative <see cref="int"/> for the
|
||||||
|
/// event-read surface (whose <c>maxEvents</c> is signed): values above <see cref="int.MaxValue"/>
|
||||||
|
/// saturate to <see cref="int.MaxValue"/>.</summary>
|
||||||
|
/// <param name="value">The uint cap from the request (<c>NumValuesPerNode</c>).</param>
|
||||||
|
/// <returns>The clamped non-negative int.</returns>
|
||||||
|
private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project a sequence of <see cref="HistoricalEvent"/>s into an SDK <see cref="HistoryEvent"/> —
|
||||||
|
/// one <see cref="HistoryEventFieldList"/> per event, each carrying the requested
|
||||||
|
/// <paramref name="selectClauses"/>' fields in select-clause order (see
|
||||||
|
/// <see cref="ProjectEventField"/>).
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="events">The historian's event rows.</param>
|
||||||
|
/// <param name="selectClauses">The request's event-filter select clauses (the fields to emit, in order).</param>
|
||||||
|
/// <returns>The populated SDK <see cref="HistoryEvent"/>.</returns>
|
||||||
|
private static HistoryEvent ProjectEvents(
|
||||||
|
IReadOnlyList<HistoricalEvent> events, SimpleAttributeOperandCollection selectClauses)
|
||||||
|
{
|
||||||
|
var fieldLists = new HistoryEventFieldListCollection(events.Count);
|
||||||
|
foreach (var evt in events)
|
||||||
|
{
|
||||||
|
var fields = new VariantCollection(selectClauses.Count);
|
||||||
|
foreach (var operand in selectClauses)
|
||||||
|
{
|
||||||
|
fields.Add(ProjectEventField(evt, operand));
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldLists.Add(new HistoryEventFieldList { EventFields = fields });
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HistoryEvent { Events = fieldLists };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Project one <see cref="HistoricalEvent"/> field requested by a select <paramref name="operand"/>
|
||||||
|
/// into a <see cref="Variant"/>. Mapping is by the operand's BrowsePath LEAF QualifiedName name
|
||||||
|
/// (case-sensitive, per the OPC UA BaseEventType field names) against the
|
||||||
|
/// <see cref="HistoricalEvent"/> shape:
|
||||||
|
/// <c>EventId</c>→ByteString, <c>SourceName</c>→String, <c>Time</c>→DateTime,
|
||||||
|
/// <c>ReceiveTime</c>→DateTime, <c>Message</c>→LocalizedText, <c>Severity</c>→UInt16. Any other
|
||||||
|
/// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
|
||||||
|
/// <see cref="Variant.Null"/> — spec-conformant: a field the server can't supply is null.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="evt">The source event row.</param>
|
||||||
|
/// <param name="operand">The select-clause operand naming the field to project.</param>
|
||||||
|
/// <returns>The projected variant, or <see cref="Variant.Null"/> for an unsupported field.</returns>
|
||||||
|
private static Variant ProjectEventField(HistoricalEvent evt, SimpleAttributeOperand operand)
|
||||||
|
{
|
||||||
|
var leaf = LeafFieldName(operand);
|
||||||
|
return leaf switch
|
||||||
|
{
|
||||||
|
// BaseEventType/EventId is a ByteString — encode the driver-specific string id as UTF-8 bytes.
|
||||||
|
"EventId" => new Variant(System.Text.Encoding.UTF8.GetBytes(evt.EventId ?? string.Empty)),
|
||||||
|
"SourceName" => new Variant(evt.SourceName), // string; null ⇒ Variant.Null
|
||||||
|
"Time" => new Variant(evt.EventTimeUtc),
|
||||||
|
"ReceiveTime" => new Variant(evt.ReceivedTimeUtc),
|
||||||
|
"Message" => new Variant(new LocalizedText(evt.Message ?? string.Empty)),
|
||||||
|
"Severity" => new Variant(evt.Severity), // UInt16
|
||||||
|
// EventType / SourceNode / ConditionName / empty path / unrecognised leaf ⇒ null (spec-conformant).
|
||||||
|
_ => Variant.Null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Extract the leaf (last) <see cref="QualifiedName.Name"/> from a select operand's BrowsePath,
|
||||||
|
/// or <c>null</c> when the BrowsePath is null/empty.</summary>
|
||||||
|
/// <param name="operand">The select-clause operand.</param>
|
||||||
|
/// <returns>The leaf field name, or null for an empty BrowsePath.</returns>
|
||||||
|
private static string? LeafFieldName(SimpleAttributeOperand operand)
|
||||||
|
{
|
||||||
|
var path = operand.BrowsePath;
|
||||||
|
if (path is null || path.Count == 0) return null;
|
||||||
|
return path[^1].Name;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Block-bridge to the historian source for one node handle and project the result onto the
|
/// Block-bridge to the historian source for one node handle and project the result onto the
|
||||||
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
|
/// service-level results/errors slots. Resolves the node's registered historian tagname first —
|
||||||
|
|||||||
+398
@@ -0,0 +1,398 @@
|
|||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Opc.Ua;
|
||||||
|
using Opc.Ua.Server;
|
||||||
|
using Shouldly;
|
||||||
|
using Xunit;
|
||||||
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||||
|
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||||
|
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||||
|
|
||||||
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Phase C Task 4 — the node-manager's OPC UA HistoryReadEvents override over equipment-folder
|
||||||
|
/// event-notifier nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness Task 3 uses),
|
||||||
|
/// wires a recording fake <see cref="IHistorianDataSource"/> BEFORE materialising an alarm condition
|
||||||
|
/// (so the alarm-owning equipment folder is promoted to an event notifier WITH the HistoryRead bit),
|
||||||
|
/// then invokes the node manager's PUBLIC <c>HistoryRead(OperationContext, …)</c> with a
|
||||||
|
/// <see cref="ReadEventDetails"/>. The base CustomNodeManager2 builds the node handles + dispatches to
|
||||||
|
/// the protected <c>HistoryReadEvents</c> override, so this exercises the real dispatch path
|
||||||
|
/// in-process — fast + deterministic, no client socket.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class NodeManagerHistoryReadEventsTests : IDisposable
|
||||||
|
{
|
||||||
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||||
|
|
||||||
|
private readonly string _pkiRoot = Path.Combine(
|
||||||
|
Path.GetTempPath(),
|
||||||
|
$"otopcua-historyreadevents-{Guid.NewGuid():N}");
|
||||||
|
|
||||||
|
/// <summary>Happy path: the fake receives (sourceName == the equipment-folder id, StartTime, EndTime,
|
||||||
|
/// maxEvents), and each returned event decodes to a HistoryEventFieldList whose EventFields are in
|
||||||
|
/// SelectClause ORDER with correctly-typed Variants (EventId ByteString, SourceName string, Time
|
||||||
|
/// DateTime, Message LocalizedText, Severity UInt16). StatusCode is Good when events are present.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_dispatches_to_source_and_projects_fields_in_select_order()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var fake = new RecordingHistorianDataSource();
|
||||||
|
// Wire the source BEFORE materialising the alarm so the folder gets the HistoryRead bit.
|
||||||
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
|
const string equipmentId = "eq-evt";
|
||||||
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
||||||
|
nm.MaterialiseAlarmCondition("alarm-1", equipmentId, "HighTemp", "OffNormalAlarm", severity: 700);
|
||||||
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
|
var evtTime = new DateTime(2026, 6, 14, 10, 0, 0, DateTimeKind.Utc);
|
||||||
|
var rcvTime = new DateTime(2026, 6, 14, 10, 0, 1, DateTimeKind.Utc);
|
||||||
|
fake.EventsResult = new HistoricalEventsResult(
|
||||||
|
new[] { new HistoricalEvent("evt-42", "Pump_001", evtTime, rcvTime, "Pump tripped", 700) }, null);
|
||||||
|
|
||||||
|
var start = DateTime.UtcNow.AddHours(-1);
|
||||||
|
var end = DateTime.UtcNow;
|
||||||
|
var details = new ReadEventDetails
|
||||||
|
{
|
||||||
|
StartTime = start,
|
||||||
|
EndTime = end,
|
||||||
|
NumValuesPerNode = 50,
|
||||||
|
Filter = SelectFilter("EventId", "SourceName", "Time", "Message", "Severity"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||||
|
|
||||||
|
// The source saw the equipment/folder id as the sourceName + the request window + cap.
|
||||||
|
fake.LastSourceName.ShouldBe(equipmentId);
|
||||||
|
fake.LastStart.ShouldBe(start);
|
||||||
|
fake.LastEnd.ShouldBe(end);
|
||||||
|
fake.LastMaxEvents.ShouldBe(50);
|
||||||
|
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
|
||||||
|
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||||
|
history.Events.Count.ShouldBe(1);
|
||||||
|
var fields = history.Events[0].EventFields;
|
||||||
|
fields.Count.ShouldBe(5);
|
||||||
|
|
||||||
|
// EventId ⇒ ByteString (UTF-8 of "evt-42").
|
||||||
|
fields[0].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-42"));
|
||||||
|
// SourceName ⇒ string.
|
||||||
|
fields[1].Value.ShouldBe("Pump_001");
|
||||||
|
// Time ⇒ DateTime.
|
||||||
|
fields[2].Value.ShouldBe(evtTime);
|
||||||
|
// Message ⇒ LocalizedText.
|
||||||
|
fields[3].Value.ShouldBeOfType<LocalizedText>().Text.ShouldBe("Pump tripped");
|
||||||
|
// Severity ⇒ UInt16.
|
||||||
|
fields[4].Value.ShouldBe((ushort)700);
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>An unsupported select operand (BrowsePath ["EventType"]) projects to Variant.Null — a field
|
||||||
|
/// the server can't supply is null (spec-conformant) — while supported siblings still project.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_unsupported_select_field_projects_null()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var fake = new RecordingHistorianDataSource();
|
||||||
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
|
const string equipmentId = "eq-unsupported";
|
||||||
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
||||||
|
nm.MaterialiseAlarmCondition("alarm-2", equipmentId, "Cond", "OffNormalAlarm", severity: 500);
|
||||||
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
|
fake.EventsResult = new HistoricalEventsResult(
|
||||||
|
new[] { new HistoricalEvent("evt-1", "Src", DateTime.UtcNow, DateTime.UtcNow, "msg", 500) }, null);
|
||||||
|
|
||||||
|
var details = new ReadEventDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
// EventType is a real BaseEventType field we cannot supply from a HistoricalEvent; EventId is.
|
||||||
|
Filter = SelectFilter("EventType", "EventId"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||||
|
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||||
|
var fields = history.Events[0].EventFields;
|
||||||
|
fields.Count.ShouldBe(2);
|
||||||
|
// EventType ⇒ Variant.Null.
|
||||||
|
fields[0].Value.ShouldBeNull();
|
||||||
|
// EventId still projects to a ByteString.
|
||||||
|
fields[1].Value.ShouldBeOfType<byte[]>().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Empty events ⇒ the notifier's StatusCode is GoodNoData (the source is wired, the window
|
||||||
|
/// just held no events).</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_empty_yields_GoodNoData()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var fake = new RecordingHistorianDataSource
|
||||||
|
{
|
||||||
|
EventsResult = new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null),
|
||||||
|
};
|
||||||
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
|
const string equipmentId = "eq-empty";
|
||||||
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
||||||
|
nm.MaterialiseAlarmCondition("alarm-3", equipmentId, "Cond", "OffNormalAlarm", severity: 300);
|
||||||
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
|
var details = new ReadEventDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
Filter = SelectFilter("EventId"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||||
|
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||||
|
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||||
|
var history = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||||
|
history.Events.ShouldBeEmpty();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A folder promoted to an event notifier while NO historian was wired (Null source at
|
||||||
|
/// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields
|
||||||
|
/// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
|
||||||
|
// Materialise the alarm while the source is still the Null default — the folder is promoted to
|
||||||
|
// SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration.
|
||||||
|
const string equipmentId = "eq-nosrc";
|
||||||
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
||||||
|
nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200);
|
||||||
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
|
// Wire a real source AFTER promotion — it must NOT retroactively make the folder a source.
|
||||||
|
var fake = new RecordingHistorianDataSource();
|
||||||
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
|
var details = new ReadEventDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
Filter = SelectFilter("EventId"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||||
|
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||||
|
fake.LastSourceName.ShouldBeNull(); // source never reached
|
||||||
|
fake.EventsCalled.ShouldBeFalse();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A node we own that is NOT a registered event-notifier source (a plain variable node) ⇒
|
||||||
|
/// BadHistoryOperationUnsupported; the source is never invoked.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_non_source_node_yields_BadHistoryOperationUnsupported()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var fake = new RecordingHistorianDataSource();
|
||||||
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
|
// A historized variable node — owns the HistoryRead bit (so the base hands it to us) but is NOT an
|
||||||
|
// event-notifier source, so the Events arm must reject it.
|
||||||
|
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
||||||
|
writable: false, historianTagname: "WW.Temp");
|
||||||
|
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
||||||
|
|
||||||
|
var details = new ReadEventDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
Filter = SelectFilter("EventId"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||||
|
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||||
|
fake.EventsCalled.ShouldBeFalse();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A backend that throws ⇒ that node's error is Bad and no exception escapes the
|
||||||
|
/// HistoryRead call.</summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task Events_backend_throw_yields_bad_status_and_does_not_escape()
|
||||||
|
{
|
||||||
|
var (host, server) = await BootAsync();
|
||||||
|
var nm = server.NodeManager!;
|
||||||
|
var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
|
||||||
|
nm.HistorianDataSource = fake;
|
||||||
|
|
||||||
|
const string equipmentId = "eq-boom";
|
||||||
|
nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment");
|
||||||
|
nm.MaterialiseAlarmCondition("alarm-5", equipmentId, "Cond", "OffNormalAlarm", severity: 900);
|
||||||
|
var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId;
|
||||||
|
|
||||||
|
var details = new ReadEventDetails
|
||||||
|
{
|
||||||
|
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||||
|
EndTime = DateTime.UtcNow,
|
||||||
|
NumValuesPerNode = 10,
|
||||||
|
Filter = SelectFilter("EventId"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// The call must not throw even though the backend does.
|
||||||
|
var (results, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId);
|
||||||
|
|
||||||
|
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
|
||||||
|
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||||
|
(results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse();
|
||||||
|
|
||||||
|
await host.DisposeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Build a HistoryReadEvents-style event filter whose select clauses are single-element
|
||||||
|
/// BrowsePaths over the supplied BaseEventType leaf field names, in order.</summary>
|
||||||
|
private static EventFilter SelectFilter(params string[] leafFieldNames)
|
||||||
|
{
|
||||||
|
var filter = new EventFilter();
|
||||||
|
foreach (var name in leafFieldNames)
|
||||||
|
{
|
||||||
|
filter.SelectClauses.Add(
|
||||||
|
new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(name))
|
||||||
|
{
|
||||||
|
AttributeId = Attributes.Value,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
|
||||||
|
/// results + errors — same session-less <see cref="OperationContext"/> pattern as Task 3's tests.</summary>
|
||||||
|
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
|
||||||
|
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
|
||||||
|
{
|
||||||
|
var context = new OperationContext(
|
||||||
|
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||||
|
|
||||||
|
var nodesToRead = new List<HistoryReadValueId> { new() { NodeId = nodeId } };
|
||||||
|
var results = new List<SdkHistoryReadResult> { null! };
|
||||||
|
var errors = new List<ServiceResult> { null! };
|
||||||
|
|
||||||
|
nm.HistoryRead(
|
||||||
|
context,
|
||||||
|
details,
|
||||||
|
TimestampsToReturn.Both,
|
||||||
|
releaseContinuationPoints: false,
|
||||||
|
nodesToRead,
|
||||||
|
results,
|
||||||
|
errors);
|
||||||
|
|
||||||
|
return (results, errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A recording fake historian source — captures the last ReadEventsAsync call's arguments and
|
||||||
|
/// returns a configured result (or throws when <see cref="ThrowOnRead"/> is set). The Raw/Processed/
|
||||||
|
/// AtTime reads delegate to the Null source (unused by these tests).</summary>
|
||||||
|
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
||||||
|
{
|
||||||
|
public bool ThrowOnRead { get; init; }
|
||||||
|
public HistoricalEventsResult EventsResult { get; set; } =
|
||||||
|
new(Array.Empty<HistoricalEvent>(), null);
|
||||||
|
|
||||||
|
public bool EventsCalled { get; private set; }
|
||||||
|
public string? LastSourceName { get; private set; }
|
||||||
|
public DateTime LastStart { get; private set; }
|
||||||
|
public DateTime LastEnd { get; private set; }
|
||||||
|
public int LastMaxEvents { get; private set; }
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadRawAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadProcessedAsync(
|
||||||
|
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||||
|
HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
|
||||||
|
NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistorianRead> ReadAtTimeAsync(
|
||||||
|
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken) =>
|
||||||
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
|
||||||
|
|
||||||
|
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||||
|
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (ThrowOnRead) throw new InvalidOperationException("backend boom for events");
|
||||||
|
EventsCalled = true;
|
||||||
|
LastSourceName = sourceName;
|
||||||
|
LastStart = startUtc;
|
||||||
|
LastEnd = endUtc;
|
||||||
|
LastMaxEvents = maxEvents;
|
||||||
|
return Task.FromResult(EventsResult);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||||
|
{
|
||||||
|
var host = new OpcUaApplicationHost(
|
||||||
|
new OpcUaApplicationHostOptions
|
||||||
|
{
|
||||||
|
ApplicationName = "OtOpcUa.HistoryReadEventsTest",
|
||||||
|
ApplicationUri = $"urn:OtOpcUa.HistoryReadEventsTest:{Guid.NewGuid():N}",
|
||||||
|
OpcUaPort = AllocateFreePort(),
|
||||||
|
PublicHostname = "localhost",
|
||||||
|
PkiStoreRoot = _pkiRoot,
|
||||||
|
},
|
||||||
|
NullLogger<OpcUaApplicationHost>.Instance);
|
||||||
|
|
||||||
|
var server = new OtOpcUaSdkServer();
|
||||||
|
await host.StartAsync(server, Ct);
|
||||||
|
return (host, server);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int AllocateFreePort()
|
||||||
|
{
|
||||||
|
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||||
|
listener.Start();
|
||||||
|
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||||
|
listener.Stop();
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Cleans up the PKI root directory.</summary>
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_pkiRoot))
|
||||||
|
{
|
||||||
|
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||||
|
catch { /* best-effort cleanup */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user