From e3c0ef7b41c7f8930ce827b36e4d9199bc4b6b66 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 14 Jun 2026 19:56:38 -0400 Subject: [PATCH] feat(historian): HistoryReadEvents over equipment-folder notifiers + event-field projection --- .../OtOpcUaNodeManager.cs | 186 ++++++++ .../NodeManagerHistoryReadEventsTests.cs | 398 ++++++++++++++++++ 2 files changed, 584 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs 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 */ } + } + } +}