diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
index ca57a6f9..bee425d6 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs
@@ -46,6 +46,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Keyed by NodeId → the actual so can
/// pass the folder to RemoveRootNotifier on teardown.
private readonly Dictionary _notifierFolders = new();
+ /// Phase C (Task 4): event-notifier folder NodeId-identifier → the event-history source
+ /// name passed to . 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
+ /// only when a real historian is wired at promotion time,
+ /// and the override resolves an inbound request's notifier NodeId
+ /// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on
+ /// .
+ private readonly ConcurrentDictionary _eventNotifierSources = new(StringComparer.Ordinal);
private FolderState? _root;
/// Initializes a new instance of the class with the OPC UA server and configuration.
@@ -170,6 +179,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
internal BaseDataVariableState? TryGetVariable(string nodeId) =>
_variables.TryGetValue(nodeId, out var variable) ? variable : null;
+ /// 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).
+ /// The folder node identifier.
+ /// The cached , or null when none is registered.
+ internal FolderState? TryGetFolder(string nodeId) =>
+ _folders.TryGetValue(nodeId, out var folder) ? folder : null;
+
///
/// Apply a value write from . Creates the
/// variable node on first call; subsequent calls update Value + StatusCode +
@@ -803,10 +820,31 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
/// Promote to and
/// register it as a root notifier (idempotent — guarded by ) so the
/// alarm condition has a notifier path to the Server object for T16's event propagation.
+ ///
+ /// Phase C (Task 4): when a real historian is wired at promotion time (the source is NOT the
+ /// ), the folder ALSO gets the
+ /// bit OR-ed in (keeping SubscribeToEvents) and registers
+ /// its NodeId identifier as an event-history source so the 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 StartAsync — 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 re-promotes it (acceptable, documented). The
+ /// HistoryRead bit + source registration happen inside the same first-time
+ /// block so the idempotency guard covers them too.
+ ///
private void EnsureFolderIsEventNotifier(FolderState folder)
{
if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return;
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);
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)
// equipment folders to event notifiers.
_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
}
}
+ ///
+ /// 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
+ /// : a miss ⇒ BadHistoryOperationUnsupported (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
+ /// for the folder's source name and projects each into a
+ /// per the request's event filter. Like the Raw/Processed/AtTime
+ /// arms this is NOT invoked under the node-manager Lock, 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.
+ ///
+ protected override void HistoryReadEvents(
+ ServerSystemContext context,
+ ReadEventDetails details,
+ TimestampsToReturn timestampsToReturn,
+ IList nodesToRead,
+ IList results,
+ IList errors,
+ List nodesToProcess,
+ IDictionary 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;
+ }
+ }
+ }
+
+ /// Clamp a request cap to a non-negative for the
+ /// event-read surface (whose maxEvents is signed): values above
+ /// saturate to .
+ /// The uint cap from the request (NumValuesPerNode).
+ /// The clamped non-negative int.
+ private static int ClampToInt(uint value) => value > int.MaxValue ? int.MaxValue : (int)value;
+
+ ///
+ /// Project a sequence of s into an SDK —
+ /// one per event, each carrying the requested
+ /// ' fields in select-clause order (see
+ /// ).
+ ///
+ /// The historian's event rows.
+ /// The request's event-filter select clauses (the fields to emit, in order).
+ /// The populated SDK .
+ private static HistoryEvent ProjectEvents(
+ IReadOnlyList 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 };
+ }
+
+ ///
+ /// Project one field requested by a select
+ /// into a . Mapping is by the operand's BrowsePath LEAF QualifiedName name
+ /// (case-sensitive, per the OPC UA BaseEventType field names) against the
+ /// shape:
+ /// EventId→ByteString, SourceName→String, Time→DateTime,
+ /// ReceiveTime→DateTime, Message→LocalizedText, Severity→UInt16. Any other
+ /// leaf (EventType / SourceNode / ConditionName / an unrecognised name) and an empty BrowsePath ⇒
+ /// — spec-conformant: a field the server can't supply is null.
+ ///
+ /// The source event row.
+ /// The select-clause operand naming the field to project.
+ /// The projected variant, or for an unsupported field.
+ 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,
+ };
+ }
+
+ /// Extract the leaf (last) from a select operand's BrowsePath,
+ /// or null when the BrowsePath is null/empty.
+ /// The select-clause operand.
+ /// The leaf field name, or null for an empty BrowsePath.
+ private static string? LeafFieldName(SimpleAttributeOperand operand)
+ {
+ var path = operand.BrowsePath;
+ if (path is null || path.Count == 0) return null;
+ return path[^1].Name;
+ }
+
///
/// 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 —
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs
new file mode 100644
index 00000000..6c4f20d7
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs
@@ -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;
+
+///
+/// Phase C Task 4 — the node-manager's OPC UA HistoryReadEvents override over equipment-folder
+/// event-notifier nodes. Boots a real (the same harness Task 3 uses),
+/// wires a recording fake 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 HistoryRead(OperationContext, …) with a
+/// . The base CustomNodeManager2 builds the node handles + dispatches to
+/// the protected HistoryReadEvents override, so this exercises the real dispatch path
+/// in-process — fast + deterministic, no client socket.
+///
+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}");
+
+ /// 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.
+ [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().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().Text.ShouldBe("Pump tripped");
+ // Severity ⇒ UInt16.
+ fields[4].Value.ShouldBe((ushort)700);
+
+ await host.DisposeAsync();
+ }
+
+ /// 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.
+ [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().ShouldBe(System.Text.Encoding.UTF8.GetBytes("evt-1"));
+
+ await host.DisposeAsync();
+ }
+
+ /// Empty events ⇒ the notifier's StatusCode is GoodNoData (the source is wired, the window
+ /// just held no events).
+ [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(), 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();
+ }
+
+ /// 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.
+ [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();
+ }
+
+ /// A node we own that is NOT a registered event-notifier source (a plain variable node) ⇒
+ /// BadHistoryOperationUnsupported; the source is never invoked.
+ [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();
+ }
+
+ /// A backend that throws ⇒ that node's error is Bad and no exception escapes the
+ /// HistoryRead call.
+ [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();
+ }
+
+ /// Build a HistoryReadEvents-style event filter whose select clauses are single-element
+ /// BrowsePaths over the supplied BaseEventType leaf field names, in order.
+ 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;
+ }
+
+ /// Invoke the node manager's public HistoryRead with a single node, returning the filled
+ /// results + errors — same session-less pattern as Task 3's tests.
+ private static (IList Results, IList 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 { new() { NodeId = nodeId } };
+ var results = new List { null! };
+ var errors = new List { null! };
+
+ nm.HistoryRead(
+ context,
+ details,
+ TimestampsToReturn.Both,
+ releaseContinuationPoints: false,
+ nodesToRead,
+ results,
+ errors);
+
+ return (results, errors);
+ }
+
+ /// A recording fake historian source — captures the last ReadEventsAsync call's arguments and
+ /// returns a configured result (or throws when is set). The Raw/Processed/
+ /// AtTime reads delegate to the Null source (unused by these tests).
+ private sealed class RecordingHistorianDataSource : IHistorianDataSource
+ {
+ public bool ThrowOnRead { get; init; }
+ public HistoricalEventsResult EventsResult { get; set; } =
+ new(Array.Empty(), 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 ReadRawAsync(
+ string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
+ CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadRawAsync(fullReference, startUtc, endUtc, maxValuesPerNode, cancellationToken);
+
+ public Task ReadProcessedAsync(
+ string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
+ HistoryAggregateType aggregate, CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadProcessedAsync(fullReference, startUtc, endUtc, interval, aggregate, cancellationToken);
+
+ public Task ReadAtTimeAsync(
+ string fullReference, IReadOnlyList timestampsUtc, CancellationToken cancellationToken) =>
+ NullHistorianDataSource.Instance.ReadAtTimeAsync(fullReference, timestampsUtc, cancellationToken);
+
+ public Task 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.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;
+ }
+
+ /// Cleans up the PKI root directory.
+ public void Dispose()
+ {
+ if (Directory.Exists(_pkiRoot))
+ {
+ try { Directory.Delete(_pkiRoot, recursive: true); }
+ catch { /* best-effort cleanup */ }
+ }
+ }
+}