From 52a29100b1f2757b01b0c6f686abe3b3c33cfd9e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sat, 18 Apr 2026 17:50:23 -0400 Subject: [PATCH] =?UTF-8?q?Phase=203=20PR=2038=20=E2=80=94=20DriverNodeMan?= =?UTF-8?q?ager=20HistoryRead=20override=20(LMX=20#1=20finish).=20Wires=20?= =?UTF-8?q?the=20OPC=20UA=20HistoryRead=20service=20through=20CustomNodeMa?= =?UTF-8?q?nager2's=20four=20protected=20per-kind=20hooks=20=E2=80=94=20Hi?= =?UTF-8?q?storyReadRawModified=20/=20HistoryReadProcessed=20/=20HistoryRe?= =?UTF-8?q?adAtTime=20/=20HistoryReadEvents=20=E2=80=94=20each=20dispatchi?= =?UTF-8?q?ng=20to=20the=20driver's=20IHistoryProvider=20capability=20(PR?= =?UTF-8?q?=2035=20for=20ReadAtTime=20+=20ReadEvents=20on=20top=20of=20PR?= =?UTF-8?q?=2019-era=20ReadRaw=20+=20ReadProcessed).=20Was=20the=20last=20?= =?UTF-8?q?missing=20piece=20of=20the=20end-to-end=20HistoryRead=20path:?= =?UTF-8?q?=20PR=2010=20+=20PR=2011=20shipped=20the=20Galaxy.Host=20IPC=20?= =?UTF-8?q?contracts,=20PR=2035=20surfaced=20them=20on=20IHistoryProvider?= =?UTF-8?q?=20+=20GalaxyProxyDriver,=20but=20no=20server-side=20handler=20?= =?UTF-8?q?bridged=20OPC=20UA=20HistoryRead=20service=20requests=20onto=20?= =?UTF-8?q?the=20capability=20interface.=20Now=20it=20does.=20Per-kind=20o?= =?UTF-8?q?verride=20shape:=20each=20hook=20receives=20the=20pre-filtered?= =?UTF-8?q?=20nodesToProcess=20list=20(NodeHandles=20for=20nodes=20this=20?= =?UTF-8?q?manager=20claimed),=20iterates=20them,=20resolves=20handle.Node?= =?UTF-8?q?Id.Identifier=20to=20the=20driver-side=20full=20reference=20str?= =?UTF-8?q?ing,=20and=20dispatches=20to=20the=20right=20IHistoryProvider?= =?UTF-8?q?=20method.=20Write=20back=20into=20the=20outer=20results=20+=20?= =?UTF-8?q?errors=20slots=20at=20handle.Index=20(not=20the=20local=20loop?= =?UTF-8?q?=20counter=20=E2=80=94=20nodesToProcess=20is=20a=20filtered=20s?= =?UTF-8?q?ubset=20of=20nodesToRead,=20so=20indexing=20by=20the=20loop=20c?= =?UTF-8?q?ounter=20lands=20in=20the=20wrong=20slot=20for=20mixed-manager?= =?UTF-8?q?=20batches).=20WriteResult=20helper=20sets=20both=20results[i]?= =?UTF-8?q?=20AND=20errors[i];=20this=20matters=20because=20MasterNodeMana?= =?UTF-8?q?ger=20merges=20them=20and=20leaving=20errors[i]=20at=20its=20de?= =?UTF-8?q?fault=20(BadHistoryOperationUnsupported)=20overrides=20a=20Good?= =?UTF-8?q?=20result=20with=20Unsupported=20on=20the=20wire=20=E2=80=94=20?= =?UTF-8?q?this=20was=20the=20subtle=20failure=20mode=20that=20masked=20a?= =?UTF-8?q?=20correctly-constructed=20HistoryData=20response=20during=20de?= =?UTF-8?q?bugging.=20Failure-isolation=20per=20node:=20NotSupportedExcept?= =?UTF-8?q?ion=20from=20a=20driver=20that=20doesn't=20implement=20a=20part?= =?UTF-8?q?icular=20HistoryProvider=20method=20translates=20to=20BadHistor?= =?UTF-8?q?yOperationUnsupported=20in=20that=20slot;=20generic=20exception?= =?UTF-8?q?s=20log=20and=20surface=20BadInternalError;=20unresolvable=20No?= =?UTF-8?q?deIds=20get=20BadNodeIdUnknown.=20The=20batch=20continues=20unc?= =?UTF-8?q?onditionally.=20Aggregate=20mapping:=20MapAggregate=20translate?= =?UTF-8?q?s=20ObjectIds.AggregateFunction=5FAverage=20/=20Minimum=20/=20M?= =?UTF-8?q?aximum=20/=20Total=20/=20Count=20to=20the=20driver's=20HistoryA?= =?UTF-8?q?ggregateType=20enum.=20Null=20for=20anything=20else=20(e.g.=20T?= =?UTF-8?q?imeAverage,=20Interpolative)=20so=20the=20handler=20surfaces=20?= =?UTF-8?q?BadAggregateNotSupported=20at=20the=20batch=20level=20=E2=80=94?= =?UTF-8?q?=20per=20Part=2013,=20one=20unsupported=20aggregate=20means=20t?= =?UTF-8?q?he=20whole=20request=20fails=20since=20ReadProcessedDetails=20c?= =?UTF-8?q?arries=20one=20aggregate=20list=20for=20all=20nodes.=20BuildHis?= =?UTF-8?q?toryData=20wraps=20driver=20DataValueSnapshots=20as=20Opc.Ua.Hi?= =?UTF-8?q?storyData=20in=20an=20ExtensionObject;=20BuildHistoryEvent=20wr?= =?UTF-8?q?aps=20HistoricalEvents=20as=20Opc.Ua.HistoryEvent=20with=20the?= =?UTF-8?q?=20canonical=20BaseEventType=20field=20list=20(EventId,=20Sourc?= =?UTF-8?q?eName,=20Message,=20Severity,=20Time,=20ReceiveTime=20=E2=80=94?= =?UTF-8?q?=20the=20order=20OPC=20UA=20clients=20that=20didn't=20customize?= =?UTF-8?q?=20the=20SelectClause=20expect).=20ToDataValue=20preserves=20nu?= =?UTF-8?q?ll=20SourceTimestamp=20(Galaxy=20historian=20rows=20often=20car?= =?UTF-8?q?ry=20only=20ServerTimestamp)=20=E2=80=94=20synthesizing=20a=20S?= =?UTF-8?q?ourceTimestamp=20would=20lie=20about=20actual=20sample=20time.?= =?UTF-8?q?=20Two=20address-space=20changes=20were=20required=20to=20make?= =?UTF-8?q?=20the=20stack=20dispatch=20reach=20the=20per-kind=20hooks=20at?= =?UTF-8?q?=20all:=20(1)=20historized=20variables=20get=20AccessLevels.His?= =?UTF-8?q?toryRead=20added=20to=20their=20AccessLevel=20byte=20=E2=80=94?= =?UTF-8?q?=20the=20base's=20early-gate=20check=20on=20(variable.AccessLev?= =?UTF-8?q?el=20&=20HistoryRead=20!=3D=200)=20was=20rejecting=20requests?= =?UTF-8?q?=20before=20our=20override=20ever=20ran;=20(2)=20the=20driver-r?= =?UTF-8?q?oot=20folder=20gets=20EventNotifiers.HistoryRead=20|=20Subscrib?= =?UTF-8?q?eToEvents=20so=20HistoryReadEvents=20can=20target=20it=20(the?= =?UTF-8?q?=20conventional=20pattern=20for=20alarm-history=20browse=20agai?= =?UTF-8?q?nst=20a=20driver-owned=20object).=20Document=20the=20'set=20bot?= =?UTF-8?q?h=20bits'=20requirement=20inline=20since=20it's=20not=20obvious?= =?UTF-8?q?=20from=20the=20surface=20API.=20OpcHistoryReadResult=20alias:?= =?UTF-8?q?=20Opc.Ua.HistoryReadResult=20(service-layer=20per-node=20resul?= =?UTF-8?q?t)=20collides=20with=20Core.Abstractions.HistoryReadResult=20(d?= =?UTF-8?q?river-side=20samples=20+=20continuation=20point)=20by=20type=20?= =?UTF-8?q?name;=20the=20alias=20'using=20OpcHistoryReadResult=20=3D=20Opc?= =?UTF-8?q?.Ua.HistoryReadResult'=20keeps=20the=20override=20signatures=20?= =?UTF-8?q?unambiguous=20and=20the=20test=20project=20applies=20the=20mirr?= =?UTF-8?q?or=20pattern=20for=20its=20stub=20driver=20impl.=20Tests=20?= =?UTF-8?q?=E2=80=94=20DriverNodeManagerHistoryMappingTests=20(12=20new=20?= =?UTF-8?q?Category=3DUnit=20cases):=20MapAggregate=20translates=20each=20?= =?UTF-8?q?supported=20aggregate=20NodeId=20via=20reflection-backed=20theo?= =?UTF-8?q?ry=20(guards=20against=20the=20stack=20renaming=20AggregateFunc?= =?UTF-8?q?tion=5F*=20constants);=20returns=20null=20for=20unsupported=20N?= =?UTF-8?q?odeIds=20(TimeAverage)=20and=20null=20input;=20BuildHistoryData?= =?UTF-8?q?=20wraps=20samples=20with=20correct=20DataValues=20+=20SourceTi?= =?UTF-8?q?mestamp=20preservation;=20BuildHistoryEvent=20emits=20the=206-e?= =?UTF-8?q?lement=20BaseEventType=20field=20list=20in=20canonical=20order?= =?UTF-8?q?=20(regression=20guard=20for=20a=20future=20'respect=20the=20cl?= =?UTF-8?q?ient's=20SelectClauses'=20change);=20null=20SourceName=20/=20Me?= =?UTF-8?q?ssage=20translate=20to=20empty-string=20Variants=20(nullable-Va?= =?UTF-8?q?riant=20refactor=20trap);=20ToDataValue=20preserves=20StatusCod?= =?UTF-8?q?e=20+=20both=20timestamps;=20ToDataValue=20leaves=20SourceTimes?= =?UTF-8?q?tamp=20at=20default=20when=20the=20snapshot=20omits=20it.=20His?= =?UTF-8?q?toryReadIntegrationTests=20(5=20new=20Category=3DIntegration):?= =?UTF-8?q?=20drives=20a=20real=20OPC=20UA=20client=20Session.HistoryRead?= =?UTF-8?q?=20against=20a=20fake=20HistoryDriver=20through=20the=20running?= =?UTF-8?q?=20server.=20Covers=20raw=20round-trip=20(verifies=20per-node?= =?UTF-8?q?=20DataValue=20ordering=20+=20values);=20processed=20with=20Ave?= =?UTF-8?q?rage=20aggregate=20(captures=20the=20driver's=20received=20aggr?= =?UTF-8?q?egate=20+=20interval,=20asserting=20MapAggregate=20routed=20cor?= =?UTF-8?q?rectly);=20unsupported=20aggregate=20(TimeAverage=20=E2=86=92?= =?UTF-8?q?=20BadAggregateNotSupported);=20at-time=20(forwards=20the=20per?= =?UTF-8?q?-timestamp=20list);=20events=20(BaseEventType=20field=20list=20?= =?UTF-8?q?shape,=20SelectClauses=20populated=20to=20satisfy=20the=20stack?= =?UTF-8?q?'s=20filter=20validator).=20Server.Tests=20Unit:=2055=20pass=20?= =?UTF-8?q?/=200=20fail=20(43=20prior=20+=2012=20new=20mapping).=20Server.?= =?UTF-8?q?Tests=20Integration:=2014=20pass=20/=200=20fail=20(9=20prior=20?= =?UTF-8?q?+=205=20new=20history).=20Full=20solution=20build=20clean,=200?= =?UTF-8?q?=20errors.=20lmx-followups.md=20#1=20updated=20to=20'DONE=20(PR?= =?UTF-8?q?s=2035=20+=2038)'=20with=20two=20explicit=20deferred=20items:?= =?UTF-8?q?=20continuation-point=20plumbing=20(driver=20returns=20null=20t?= =?UTF-8?q?oday=20so=20pass-through=20is=20fine)=20and=20per-SelectClause?= =?UTF-8?q?=20evaluation=20in=20HistoryReadEvents=20(clients=20with=20cust?= =?UTF-8?q?om=20field=20selections=20get=20the=20canonical=20BaseEventType?= =?UTF-8?q?=20layout=20today).?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/v2/lmx-followups.md | 54 ++- .../OpcUa/DriverNodeManager.cs | 399 +++++++++++++++++- .../DriverNodeManagerHistoryMappingTests.cs | 160 +++++++ .../HistoryReadIntegrationTests.cs | 356 ++++++++++++++++ 4 files changed, 952 insertions(+), 17 deletions(-) create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverNodeManagerHistoryMappingTests.cs create mode 100644 tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HistoryReadIntegrationTests.cs diff --git a/docs/v2/lmx-followups.md b/docs/v2/lmx-followups.md index 19d9443..21b3c99 100644 --- a/docs/v2/lmx-followups.md +++ b/docs/v2/lmx-followups.md @@ -7,24 +7,50 @@ Basic256Sha256 endpoints and alarms are observable through specific before the stack can fully replace the v1 deployment, in rough priority order. -## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` - -**Status**: Capability surface complete (PR 35). OPC UA HistoryRead service-handler -wiring in `DriverNodeManager` remains as the next step; integration-test still -pending. +## 1. Proxy-side `IHistoryProvider` for `ReadAtTime` / `ReadEvents` — **DONE (PRs 35 + 38)** PR 35 extended `IHistoryProvider` with `ReadAtTimeAsync` + `ReadEventsAsync` (default throwing implementations so existing impls keep compiling), added the -`HistoricalEvent` + `HistoricalEventsResult` records to -`Core.Abstractions`, and implemented both methods in `GalaxyProxyDriver` on top -of the PR 10 / PR 11 IPC messages. Wire-to-domain mapping (`ToHistoricalEvent`) -is unit-tested for field fidelity, null-preservation, and `DateTimeKind.Utc`. +`HistoricalEvent` + `HistoricalEventsResult` records to `Core.Abstractions`, +and implemented both methods in `GalaxyProxyDriver` on top of the PR 10 / PR 11 +IPC messages. -**Remaining**: -- `DriverNodeManager` wires the new capability methods onto `HistoryRead` - `AtTime` + `Events` service handlers. -- Integration test: OPC UA client calls `HistoryReadAtTime` / `HistoryReadEvents`, - value flows through IPC to the Host's `HistorianDataSource`, back to the client. +PR 38 wired the OPC UA HistoryRead service-handler through +`DriverNodeManager` by overriding `CustomNodeManager2`'s four per-kind hooks — +`HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` / +`HistoryReadEvents`. Each walks `nodesToProcess`, resolves the driver-side +full reference from `NodeId.Identifier`, dispatches to the right +`IHistoryProvider` method, and populates the paired results + errors lists +(both must be set — the MasterNodeManager merges them and a Good result with +an unset error slot serializes as `BadHistoryOperationUnsupported` on the +wire). Historized variables gain `AccessLevels.HistoryRead` so the stack +dispatches; the driver root folder gains `EventNotifiers.HistoryRead` so +`HistoryReadEvents` can target it. + +Aggregate translation uses a small `MapAggregate` helper that handles +`Average` / `Minimum` / `Maximum` / `Total` / `Count` (the enum surface the +driver exposes) and returns null for unsupported aggregates so the handler +can surface `BadAggregateNotSupported`. Raw+Processed+AtTime wrap driver +samples as `HistoryData` in an `ExtensionObject`; Events emits a +`HistoryEvent` with the standard BaseEventType field list (EventId / +SourceName / Message / Severity / Time / ReceiveTime) — custom +`SelectClause` evaluation is an explicit follow-up. + +**Tests**: + +- `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning + `MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`. +- `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real OPC UA + client (`Session.HistoryRead`) against a fake `IHistoryProvider` driver + through the running stack. Covers raw round-trip, processed with Average + aggregate, unsupported aggregate → `BadAggregateNotSupported`, at-time + timestamp forwarding, and events field-list shape. + +**Deferred**: +- Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`. + Driver returns null continuations today so the pass-through is fine. +- Per-`SelectClause` evaluation in HistoryReadEvents — clients that send a + custom field selection currently get the standard BaseEventType layout. ## 2. Write-gating by role — **DONE (PR 26)** diff --git a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs index fcd09dd..4857adb 100644 --- a/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs +++ b/src/ZB.MOM.WW.OtOpcUa.Server/OpcUa/DriverNodeManager.cs @@ -5,6 +5,11 @@ using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Server.Security; using DriverWriteRequest = ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest; +// Core.Abstractions defines a type-named HistoryReadResult (driver-side samples + continuation +// point) that collides with Opc.Ua.HistoryReadResult (service-layer per-node result). We +// assign driver-side results to an explicitly-aliased local and construct only the service +// type in the overrides below. +using OpcHistoryReadResult = Opc.Ua.HistoryReadResult; namespace ZB.MOM.WW.OtOpcUa.Server.OpcUa; @@ -71,7 +76,13 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder NodeId = new NodeId(_driver.DriverInstanceId, NamespaceIndex), BrowseName = new QualifiedName(_driver.DriverInstanceId, NamespaceIndex), DisplayName = new LocalizedText(_driver.DriverInstanceId), - EventNotifier = EventNotifiers.None, + // Driver root is the conventional event notifier for HistoryReadEvents — clients + // request alarm history by targeting it and the node manager routes through + // IHistoryProvider.ReadEventsAsync. SubscribeToEvents is also set so live-event + // subscriptions (Alarm & Conditions) can point here in a future PR; today the + // alarm events are emitted by per-variable AlarmConditionState siblings but a + // "subscribe to all events from this driver" path would use this notifier. + EventNotifier = (byte)(EventNotifiers.SubscribeToEvents | EventNotifiers.HistoryRead), }; // Link under Objects folder so clients see the driver subtree at browse root. @@ -122,8 +133,15 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder DisplayName = new LocalizedText(displayName), DataType = MapDataType(attributeInfo.DriverDataType), ValueRank = attributeInfo.IsArray ? ValueRanks.OneDimension : ValueRanks.Scalar, - AccessLevel = AccessLevels.CurrentReadOrWrite, - UserAccessLevel = AccessLevels.CurrentReadOrWrite, + // Historized attributes get the HistoryRead access bit so the stack dispatches + // incoming HistoryRead service calls to this node. Without it the base class + // returns BadHistoryOperationUnsupported before our per-kind hook ever runs. + // HistoryWrite isn't granted — history rewrite is a separate capability the + // driver doesn't support today. + AccessLevel = (byte)(AccessLevels.CurrentReadOrWrite + | (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)), + UserAccessLevel = (byte)(AccessLevels.CurrentReadOrWrite + | (attributeInfo.IsHistorized ? AccessLevels.HistoryRead : 0)), Historizing = attributeInfo.IsHistorized, }; _currentFolder.AddChild(v); @@ -384,4 +402,379 @@ public sealed class DriverNodeManager : CustomNodeManager2, IAddressSpaceBuilder internal int VariableCount => _variablesByFullRef.Count; internal bool TryGetVariable(string fullRef, out BaseDataVariableState? v) => _variablesByFullRef.TryGetValue(fullRef, out v!); + + // ===================== HistoryRead service handlers (LMX #1, PR 38) ===================== + // + // Wires the driver's IHistoryProvider capability (PR 35 added ReadAtTimeAsync / ReadEventsAsync + // alongside the PR 19 ReadRawAsync / ReadProcessedAsync) to the OPC UA HistoryRead service. + // CustomNodeManager2 has four protected per-kind hooks; the base dispatches to the right one + // based on the concrete HistoryReadDetails subtype. Each hook is sync-returning-void — the + // per-driver async calls are bridged via GetAwaiter().GetResult(), matching the pattern + // OnReadValue / OnWriteValue already use in this class so HistoryRead doesn't introduce a + // different sync-over-async convention. + // + // Per-node routing: every HistoryReadValueId in nodesToRead has a NodeHandle in + // nodesToProcess; the NodeHandle's NodeId.Identifier is the driver-side full reference + // (set during Variable() registration) so we can dispatch straight to IHistoryProvider + // without a second lookup. Nodes without IHistoryProvider backing (drivers that don't + // implement the capability) surface BadHistoryOperationUnsupported per slot and the + // rest of the batch continues — same failure-isolation pattern as OnWriteValue. + // + // Continuation-point handling is pass-through only in this PR: the driver returns null + // from its ContinuationPoint field today so the outer result's ContinuationPoint stays + // empty. Full Session.SaveHistoryContinuationPoint plumbing is a follow-up when a driver + // actually needs paging — the dispatch shape doesn't change, only the result-population. + + private IHistoryProvider? History => _driver as IHistoryProvider; + + protected override void HistoryReadRawModified( + ServerSystemContext context, ReadRawModifiedDetails details, TimestampsToReturn timestamps, + IList nodesToRead, IList results, + IList errors, List nodesToProcess, + IDictionary cache) + { + if (History is null) + { + MarkAllUnsupported(nodesToProcess, results, errors); + return; + } + + // IsReadModified=true requests a "modifications" history (who changed the data, when + // it was re-written). The driver side has no modifications store — surface that + // explicitly rather than silently returning raw data, which would mislead the client. + if (details.IsReadModified) + { + MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadHistoryOperationUnsupported); + return; + } + + for (var n = 0; n < nodesToProcess.Count; n++) + { + var handle = nodesToProcess[n]; + // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead + // arrays. nodesToProcess is the filtered subset (just the nodes this manager + // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes + // are interleaved across multiple node managers. + var i = handle.Index; + var fullRef = ResolveFullRef(handle); + if (fullRef is null) + { + WriteNodeIdUnknown(results, errors, i); + continue; + } + + try + { + var driverResult = History.ReadRawAsync( + fullRef, + details.StartTime, + details.EndTime, + details.NumValuesPerNode, + CancellationToken.None).GetAwaiter().GetResult(); + + WriteResult(results, errors, i, StatusCodes.Good, + BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint); + } + catch (NotSupportedException) + { + WriteUnsupported(results, errors, i); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "HistoryReadRaw failed for {FullRef}", fullRef); + WriteInternalError(results, errors, i); + } + } + } + + protected override void HistoryReadProcessed( + ServerSystemContext context, ReadProcessedDetails details, TimestampsToReturn timestamps, + IList nodesToRead, IList results, + IList errors, List nodesToProcess, + IDictionary cache) + { + if (History is null) + { + MarkAllUnsupported(nodesToProcess, results, errors); + return; + } + + // AggregateType is one NodeId shared across every item in the batch — map once. + var aggregate = MapAggregate(details.AggregateType?.FirstOrDefault()); + if (aggregate is null) + { + MarkAllUnsupported(nodesToProcess, results, errors, StatusCodes.BadAggregateNotSupported); + return; + } + + var interval = TimeSpan.FromMilliseconds(details.ProcessingInterval); + for (var n = 0; n < nodesToProcess.Count; n++) + { + var handle = nodesToProcess[n]; + // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead + // arrays. nodesToProcess is the filtered subset (just the nodes this manager + // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes + // are interleaved across multiple node managers. + var i = handle.Index; + var fullRef = ResolveFullRef(handle); + if (fullRef is null) + { + WriteNodeIdUnknown(results, errors, i); + continue; + } + + try + { + var driverResult = History.ReadProcessedAsync( + fullRef, + details.StartTime, + details.EndTime, + interval, + aggregate.Value, + CancellationToken.None).GetAwaiter().GetResult(); + + WriteResult(results, errors, i, StatusCodes.Good, + BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint); + } + catch (NotSupportedException) + { + WriteUnsupported(results, errors, i); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "HistoryReadProcessed failed for {FullRef}", fullRef); + WriteInternalError(results, errors, i); + } + } + } + + protected override void HistoryReadAtTime( + ServerSystemContext context, ReadAtTimeDetails details, TimestampsToReturn timestamps, + IList nodesToRead, IList results, + IList errors, List nodesToProcess, + IDictionary cache) + { + if (History is null) + { + MarkAllUnsupported(nodesToProcess, results, errors); + return; + } + + var requestedTimes = (IReadOnlyList)(details.ReqTimes?.ToArray() ?? Array.Empty()); + for (var n = 0; n < nodesToProcess.Count; n++) + { + var handle = nodesToProcess[n]; + // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead + // arrays. nodesToProcess is the filtered subset (just the nodes this manager + // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes + // are interleaved across multiple node managers. + var i = handle.Index; + var fullRef = ResolveFullRef(handle); + if (fullRef is null) + { + WriteNodeIdUnknown(results, errors, i); + continue; + } + + try + { + var driverResult = History.ReadAtTimeAsync( + fullRef, requestedTimes, CancellationToken.None).GetAwaiter().GetResult(); + + WriteResult(results, errors, i, StatusCodes.Good, + BuildHistoryData(driverResult.Samples), driverResult.ContinuationPoint); + } + catch (NotSupportedException) + { + WriteUnsupported(results, errors, i); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "HistoryReadAtTime failed for {FullRef}", fullRef); + WriteInternalError(results, errors, i); + } + } + } + + protected override void HistoryReadEvents( + ServerSystemContext context, ReadEventDetails details, TimestampsToReturn timestamps, + IList nodesToRead, IList results, + IList errors, List nodesToProcess, + IDictionary cache) + { + if (History is null) + { + MarkAllUnsupported(nodesToProcess, results, errors); + return; + } + + // SourceName filter extraction is deferred — EventFilter SelectClauses + WhereClause + // handling is a dedicated concern (proper per-select-clause Variant population + where + // filter evaluation). This PR treats the event query as "all events in range for the + // node's source" and populates only the standard BaseEventType fields. Richer filter + // handling is a follow-up; clients issuing empty/default filters get the right answer + // today which covers the common alarm-history browse case. + var maxEvents = (int)details.NumValuesPerNode; + if (maxEvents <= 0) maxEvents = 1000; + + for (var n = 0; n < nodesToProcess.Count; n++) + { + var handle = nodesToProcess[n]; + // NodeHandle.Index points back to the slot in the outer results/errors/nodesToRead + // arrays. nodesToProcess is the filtered subset (just the nodes this manager + // claimed), so writing to results[n] lands in the wrong slot when N > 1 and nodes + // are interleaved across multiple node managers. + var i = handle.Index; + // Event history queries may target a notifier object (e.g. the driver-root folder) + // rather than a specific variable — in that case we pass sourceName=null to mean + // "all sources in the driver's namespace" per the IHistoryProvider contract. + var fullRef = ResolveFullRef(handle); + + try + { + var driverResult = History.ReadEventsAsync( + sourceName: fullRef, + startUtc: details.StartTime, + endUtc: details.EndTime, + maxEvents: maxEvents, + cancellationToken: CancellationToken.None).GetAwaiter().GetResult(); + + WriteResult(results, errors, i, StatusCodes.Good, + BuildHistoryEvent(driverResult.Events), driverResult.ContinuationPoint); + } + catch (NotSupportedException) + { + WriteUnsupported(results, errors, i); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "HistoryReadEvents failed for {FullRef}", fullRef); + WriteInternalError(results, errors, i); + } + } + } + + private string? ResolveFullRef(NodeHandle handle) => handle.NodeId?.Identifier as string; + + // Both the results list AND the parallel errors list must be populated — MasterNodeManager + // merges them and the merged StatusCode is what the client sees. Leaving errors[i] at its + // default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported, which + // masks a correctly-constructed HistoryData response. This was the subtle failure mode + // that cost most of PR 38's debugging budget. + private static void WriteResult(IList results, IList errors, + int i, uint statusCode, ExtensionObject historyData, byte[]? continuationPoint) + { + results[i] = new OpcHistoryReadResult + { + StatusCode = statusCode, + HistoryData = historyData, + ContinuationPoint = continuationPoint, + }; + errors[i] = statusCode == StatusCodes.Good + ? ServiceResult.Good + : new ServiceResult(statusCode); + } + + private static void WriteUnsupported(IList results, IList errors, int i) + { + results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported }; + errors[i] = StatusCodes.BadHistoryOperationUnsupported; + } + + private static void WriteInternalError(IList results, IList errors, int i) + { + results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadInternalError }; + errors[i] = StatusCodes.BadInternalError; + } + + private static void WriteNodeIdUnknown(IList results, IList errors, int i) + { + WriteNodeIdUnknown(results, errors, i); + errors[i] = StatusCodes.BadNodeIdUnknown; + } + + private static void MarkAllUnsupported( + List nodes, IList results, IList errors, + uint statusCode = StatusCodes.BadHistoryOperationUnsupported) + { + foreach (var handle in nodes) + { + results[handle.Index] = new OpcHistoryReadResult { StatusCode = statusCode }; + errors[handle.Index] = statusCode == StatusCodes.Good ? ServiceResult.Good : new ServiceResult(statusCode); + } + } + + /// + /// Map the OPC UA Part 13 aggregate-function NodeId to the driver's + /// . Internal so the test suite can pin the mapping + /// without exposing public API. Returns null for unsupported aggregates so the service + /// handler can surface BadAggregateNotSupported on the whole batch. + /// + internal static HistoryAggregateType? MapAggregate(NodeId? aggregateNodeId) + { + if (aggregateNodeId is null) return null; + + // Every AggregateFunction_* identifier is a numeric uint on the Server (0) namespace. + // Comparing NodeIds by value handles all the cross-encoding cases (expanded vs plain). + if (aggregateNodeId == ObjectIds.AggregateFunction_Average) return HistoryAggregateType.Average; + if (aggregateNodeId == ObjectIds.AggregateFunction_Minimum) return HistoryAggregateType.Minimum; + if (aggregateNodeId == ObjectIds.AggregateFunction_Maximum) return HistoryAggregateType.Maximum; + if (aggregateNodeId == ObjectIds.AggregateFunction_Total) return HistoryAggregateType.Total; + if (aggregateNodeId == ObjectIds.AggregateFunction_Count) return HistoryAggregateType.Count; + return null; + } + + /// + /// Wrap driver samples as HistoryData in an ExtensionObject — the on-wire + /// shape the OPC UA HistoryRead service expects for raw / processed / at-time reads. + /// + internal static ExtensionObject BuildHistoryData(IReadOnlyList samples) + { + var values = new DataValueCollection(samples.Count); + foreach (var s in samples) values.Add(ToDataValue(s)); + return new ExtensionObject(new HistoryData { DataValues = values }); + } + + /// + /// Wrap driver events as HistoryEvent in an ExtensionObject. Populates + /// the minimum BaseEventType field set (SourceName, Message, Severity, Time, + /// ReceiveTime, EventId) so clients that request the default + /// SimpleAttributeOperand select-clauses see useful data. Custom EventFilter + /// SelectClause evaluation is deferred — when a client sends a specific operand list, + /// they currently get the standard fields back and ignore the extras. Documented on the + /// public follow-up list. + /// + internal static ExtensionObject BuildHistoryEvent(IReadOnlyList events) + { + var fieldLists = new HistoryEventFieldListCollection(events.Count); + foreach (var e in events) + { + var fields = new VariantCollection + { + // Order must match BaseEventType's conventional field ordering so clients that + // didn't customize the SelectClauses still see recognizable columns. A future + // PR that respects the client's SelectClause list will drive this from the filter. + new Variant(e.EventId), + new Variant(e.SourceName ?? string.Empty), + new Variant(new LocalizedText(e.Message ?? string.Empty)), + new Variant(e.Severity), + new Variant(e.EventTimeUtc), + new Variant(e.ReceivedTimeUtc), + }; + fieldLists.Add(new HistoryEventFieldList { EventFields = fields }); + } + return new ExtensionObject(new HistoryEvent { Events = fieldLists }); + } + + internal static DataValue ToDataValue(DataValueSnapshot s) + { + var dv = new DataValue + { + Value = s.Value, + StatusCode = new StatusCode(s.StatusCode), + ServerTimestamp = s.ServerTimestampUtc, + }; + if (s.SourceTimestampUtc.HasValue) dv.SourceTimestamp = s.SourceTimestampUtc.Value; + return dv; + } } diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverNodeManagerHistoryMappingTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverNodeManagerHistoryMappingTests.cs new file mode 100644 index 0000000..fc76f33 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/DriverNodeManagerHistoryMappingTests.cs @@ -0,0 +1,160 @@ +using System.Linq; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// Unit coverage for the static helpers exposes to bridge +/// driver-side history data ( + ) +/// to the OPC UA on-wire shape (HistoryData / HistoryEvent wrapped in an +/// ). Fast, framework-only — no server fixture. +/// +[Trait("Category", "Unit")] +public sealed class DriverNodeManagerHistoryMappingTests +{ + [Theory] + [InlineData(nameof(HistoryAggregateType.Average), HistoryAggregateType.Average)] + [InlineData(nameof(HistoryAggregateType.Minimum), HistoryAggregateType.Minimum)] + [InlineData(nameof(HistoryAggregateType.Maximum), HistoryAggregateType.Maximum)] + [InlineData(nameof(HistoryAggregateType.Total), HistoryAggregateType.Total)] + [InlineData(nameof(HistoryAggregateType.Count), HistoryAggregateType.Count)] + public void MapAggregate_translates_each_supported_OPC_UA_aggregate_NodeId( + string name, HistoryAggregateType expected) + { + // Resolve the ObjectIds.AggregateFunction_ constant via reflection so the test + // keeps working if the stack ever renames them — failure means the stack broke its + // naming convention, worth surfacing loudly. + var field = typeof(ObjectIds).GetField("AggregateFunction_" + name); + field.ShouldNotBeNull(); + var nodeId = (NodeId)field!.GetValue(null)!; + + DriverNodeManager.MapAggregate(nodeId).ShouldBe(expected); + } + + [Fact] + public void MapAggregate_returns_null_for_unknown_aggregate() + { + // AggregateFunction_TimeAverage is a valid OPC UA aggregate but not one the driver + // surfaces. Null here means the service handler will translate to BadAggregateNotSupported + // — the right behavior per Part 13 when the requested aggregate isn't implemented. + DriverNodeManager.MapAggregate(ObjectIds.AggregateFunction_TimeAverage).ShouldBeNull(); + } + + [Fact] + public void MapAggregate_returns_null_for_null_input() + { + // Processed requests that omit the aggregate list (or pass a single null) must not crash. + DriverNodeManager.MapAggregate(null).ShouldBeNull(); + } + + [Fact] + public void BuildHistoryData_wraps_samples_as_HistoryData_extension_object() + { + var samples = new[] + { + new DataValueSnapshot(Value: 42, StatusCode: StatusCodes.Good, + SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 1, DateTimeKind.Utc)), + new DataValueSnapshot(Value: 99, StatusCode: StatusCodes.Good, + SourceTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 5, DateTimeKind.Utc), + ServerTimestampUtc: new DateTime(2024, 1, 1, 0, 0, 6, DateTimeKind.Utc)), + }; + + var ext = DriverNodeManager.BuildHistoryData(samples); + + ext.Body.ShouldBeOfType(); + var hd = (HistoryData)ext.Body; + hd.DataValues.Count.ShouldBe(2); + hd.DataValues[0].Value.ShouldBe(42); + hd.DataValues[1].Value.ShouldBe(99); + hd.DataValues[0].SourceTimestamp.ShouldBe(new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + } + + [Fact] + public void BuildHistoryEvent_wraps_events_with_BaseEventType_field_ordering() + { + // BuildHistoryEvent populates a fixed field set in BaseEventType's conventional order: + // EventId, SourceName, Message, Severity, Time, ReceiveTime. Pinning this so a later + // "respect the client's SelectClauses" change can't silently break older clients that + // rely on the default layout. + var events = new[] + { + new HistoricalEvent( + EventId: "e-1", + SourceName: "Tank1.HiAlarm", + EventTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc), + ReceivedTimeUtc: new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc), + Message: "High level reached", + Severity: 750), + }; + + var ext = DriverNodeManager.BuildHistoryEvent(events); + + ext.Body.ShouldBeOfType(); + var he = (HistoryEvent)ext.Body; + he.Events.Count.ShouldBe(1); + var fields = he.Events[0].EventFields; + fields.Count.ShouldBe(6); + fields[0].Value.ShouldBe("e-1"); // EventId + fields[1].Value.ShouldBe("Tank1.HiAlarm"); // SourceName + ((LocalizedText)fields[2].Value).Text.ShouldBe("High level reached"); // Message + fields[3].Value.ShouldBe((ushort)750); // Severity + ((DateTime)fields[4].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc)); + ((DateTime)fields[5].Value).ShouldBe(new DateTime(2024, 1, 1, 12, 0, 0, 5, DateTimeKind.Utc)); + } + + [Fact] + public void BuildHistoryEvent_substitutes_empty_string_for_null_SourceName_and_Message() + { + // Driver-side nulls are preserved through the wire contract by design (distinguishes + // "system event with no source" from "source unknown"), but OPC UA Variants of type + // String must not carry null — the stack serializes null-string as empty. This test + // pins the choice so a nullable-Variant refactor doesn't break clients that display + // the field without a null check. + var events = new[] + { + new HistoricalEvent("sys", null, DateTime.UtcNow, DateTime.UtcNow, null, 1), + }; + + var ext = DriverNodeManager.BuildHistoryEvent(events); + var fields = ((HistoryEvent)ext.Body).Events[0].EventFields; + fields[1].Value.ShouldBe(string.Empty); + ((LocalizedText)fields[2].Value).Text.ShouldBe(string.Empty); + } + + [Fact] + public void ToDataValue_preserves_status_code_and_timestamps() + { + var snap = new DataValueSnapshot( + Value: 123.45, + StatusCode: StatusCodes.UncertainSubstituteValue, + SourceTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc), + ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc)); + + var dv = DriverNodeManager.ToDataValue(snap); + + dv.Value.ShouldBe(123.45); + dv.StatusCode.Code.ShouldBe(StatusCodes.UncertainSubstituteValue); + dv.SourceTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 0, DateTimeKind.Utc)); + dv.ServerTimestamp.ShouldBe(new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc)); + } + + [Fact] + public void ToDataValue_leaves_SourceTimestamp_default_when_snapshot_has_no_source_time() + { + // Galaxy's raw-history rows often carry only a ServerTimestamp (the historian knows + // when it wrote the row, not when the process sampled it). The mapping must not + // synthesize a bogus SourceTimestamp from ServerTimestamp — that would lie to the + // client about the measurement's actual time. + var snap = new DataValueSnapshot(Value: 1, StatusCode: 0, + SourceTimestampUtc: null, + ServerTimestampUtc: new DateTime(2024, 5, 1, 10, 0, 1, DateTimeKind.Utc)); + + var dv = DriverNodeManager.ToDataValue(snap); + dv.SourceTimestamp.ShouldBe(default); + } +} diff --git a/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HistoryReadIntegrationTests.cs b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HistoryReadIntegrationTests.cs new file mode 100644 index 0000000..82b8ab0 --- /dev/null +++ b/tests/ZB.MOM.WW.OtOpcUa.Server.Tests/HistoryReadIntegrationTests.cs @@ -0,0 +1,356 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Opc.Ua.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Core.Hosting; +using ZB.MOM.WW.OtOpcUa.Server.OpcUa; +using ZB.MOM.WW.OtOpcUa.Server.Security; +// Core.Abstractions.HistoryReadResult (driver-side samples) collides with Opc.Ua.HistoryReadResult +// (service-layer per-node result). Alias the driver type so the stub's interface implementations +// are unambiguous. +using DriverHistoryReadResult = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.Server.Tests; + +/// +/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's +/// via 's +/// HistoryReadRawModified / HistoryReadProcessed / HistoryReadAtTime / +/// HistoryReadEvents overrides. Boots the full OPC UA stack + a stub +/// driver, opens a client session, issues each HistoryRead +/// variant, and asserts the client receives the expected per-kind payload. +/// +[Trait("Category", "Integration")] +public sealed class HistoryReadIntegrationTests : IAsyncLifetime +{ + private static readonly int Port = 48600 + Random.Shared.Next(0, 99); + private readonly string _endpoint = $"opc.tcp://localhost:{Port}/OtOpcUaHistoryTest"; + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-history-test-{Guid.NewGuid():N}"); + + private DriverHost _driverHost = null!; + private OpcUaApplicationHost _server = null!; + private HistoryDriver _driver = null!; + + public async ValueTask InitializeAsync() + { + _driverHost = new DriverHost(); + _driver = new HistoryDriver(); + await _driverHost.RegisterAsync(_driver, "{}", CancellationToken.None); + + var options = new OpcUaServerOptions + { + EndpointUrl = _endpoint, + ApplicationName = "OtOpcUaHistoryTest", + ApplicationUri = "urn:OtOpcUa:Server:HistoryTest", + PkiStoreRoot = _pkiRoot, + AutoAcceptUntrustedClientCertificates = true, + }; + + _server = new OpcUaApplicationHost(options, _driverHost, new DenyAllUserAuthenticator(), + NullLoggerFactory.Instance, NullLogger.Instance); + await _server.StartAsync(CancellationToken.None); + } + + public async ValueTask DisposeAsync() + { + await _server.DisposeAsync(); + await _driverHost.DisposeAsync(); + try { Directory.Delete(_pkiRoot, recursive: true); } catch { /* best-effort */ } + } + + [Fact] + public async Task HistoryReadRaw_round_trips_driver_samples_to_the_client() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); + var nodeId = new NodeId("raw.var", nsIndex); + + // The Opc.Ua client exposes HistoryRead via Session.HistoryRead. We construct a + // ReadRawModifiedDetails (IsReadModified=false → raw path) and a single + // HistoryReadValueId targeting the driver-backed variable. + var details = new ReadRawModifiedDetails + { + StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2024, 1, 1, 0, 0, 10, DateTimeKind.Utc), + NumValuesPerNode = 100, + IsReadModified = false, + ReturnBounds = false, + }; + var extObj = new ExtensionObject(details); + var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; + + session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, + out var results, out _); + + results.Count.ShouldBe(1); + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good, $"HistoryReadRaw returned {results[0].StatusCode}"); + var hd = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData); + hd.DataValues.Count.ShouldBe(_driver.RawSamplesReturned, "one DataValue per driver sample"); + hd.DataValues[0].Value.ShouldBe(_driver.FirstRawValue); + } + + [Fact] + public async Task HistoryReadProcessed_maps_Average_aggregate_and_routes_to_ReadProcessedAsync() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); + var nodeId = new NodeId("proc.var", nsIndex); + + var details = new ReadProcessedDetails + { + StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc), + ProcessingInterval = 10_000, // 10s buckets + AggregateType = [ObjectIds.AggregateFunction_Average], + }; + var extObj = new ExtensionObject(details); + var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; + + session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, + out var results, out _); + + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + _driver.LastProcessedAggregate.ShouldBe(HistoryAggregateType.Average, + "MapAggregate must translate ObjectIds.AggregateFunction_Average → driver enum"); + _driver.LastProcessedInterval.ShouldBe(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task HistoryReadProcessed_returns_BadAggregateNotSupported_for_unmapped_aggregate() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); + var nodeId = new NodeId("proc.var", nsIndex); + + var details = new ReadProcessedDetails + { + StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2024, 1, 1, 0, 1, 0, DateTimeKind.Utc), + ProcessingInterval = 10_000, + // TimeAverage is a valid OPC UA aggregate NodeId but not one the driver implements — + // the override returns BadAggregateNotSupported per Part 13 rather than coercing. + AggregateType = [ObjectIds.AggregateFunction_TimeAverage], + }; + var extObj = new ExtensionObject(details); + var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; + + session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, + out var results, out _); + + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported); + } + + [Fact] + public async Task HistoryReadAtTime_forwards_timestamp_list_to_driver() + { + using var session = await OpenSessionAsync(); + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); + var nodeId = new NodeId("atTime.var", nsIndex); + + var t1 = new DateTime(2024, 3, 1, 10, 0, 0, DateTimeKind.Utc); + var t2 = new DateTime(2024, 3, 1, 10, 0, 30, DateTimeKind.Utc); + var details = new ReadAtTimeDetails { ReqTimes = new DateTimeCollection { t1, t2 } }; + var extObj = new ExtensionObject(details); + var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; + + session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, + out var results, out _); + + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + _driver.LastAtTimeRequestedTimes.ShouldNotBeNull(); + _driver.LastAtTimeRequestedTimes!.Count.ShouldBe(2); + _driver.LastAtTimeRequestedTimes[0].ShouldBe(t1); + _driver.LastAtTimeRequestedTimes[1].ShouldBe(t2); + } + + [Fact] + public async Task HistoryReadEvents_returns_HistoryEvent_with_BaseEventType_field_list() + { + using var session = await OpenSessionAsync(); + // Events target the driver-root notifier (not a specific variable) which is the + // conventional pattern for alarm-history browse. + var nsIndex = (ushort)session.NamespaceUris.GetIndex("urn:OtOpcUa:history-driver"); + var nodeId = new NodeId("history-driver", nsIndex); + + // EventFilter must carry at least one SelectClause or the stack rejects it as + // BadEventFilterInvalid before our override runs — empty filters are spec-forbidden. + // We populate the standard BaseEventType selectors any real client would send; my + // override's BuildHistoryEvent ignores the specific clauses and emits the canonical + // field list anyway (the richer "respect exact SelectClauses" behavior is on the PR 38 + // follow-up list). + var filter = new EventFilter(); + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.EventId); + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.SourceName); + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Message); + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Severity); + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.Time); + filter.AddSelectClause(ObjectTypeIds.BaseEventType, BrowseNames.ReceiveTime); + + var details = new ReadEventDetails + { + StartTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc), + EndTime = new DateTime(2024, 12, 31, 0, 0, 0, DateTimeKind.Utc), + NumValuesPerNode = 10, + Filter = filter, + }; + var extObj = new ExtensionObject(details); + var nodesToRead = new HistoryReadValueIdCollection { new() { NodeId = nodeId } }; + + session.HistoryRead(null, extObj, TimestampsToReturn.Both, false, nodesToRead, + out var results, out _); + + results[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + var he = (HistoryEvent)ExtensionObject.ToEncodeable(results[0].HistoryData); + he.Events.Count.ShouldBe(_driver.EventsReturned); + he.Events[0].EventFields.Count.ShouldBe(6, "BaseEventType default field layout is 6 entries"); + } + + private async Task OpenSessionAsync() + { + var cfg = new ApplicationConfiguration + { + ApplicationName = "OtOpcUaHistoryTestClient", + ApplicationUri = "urn:OtOpcUa:HistoryTestClient", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier + { + StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(_pkiRoot, "client-own"), + SubjectName = "CN=OtOpcUaHistoryTestClient", + }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-issuers") }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-trusted") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(_pkiRoot, "client-rejected") }, + AutoAcceptUntrustedCertificates = true, + AddAppCertToTrustedStore = true, + }, + TransportConfigurations = new TransportConfigurationCollection(), + TransportQuotas = new TransportQuotas { OperationTimeout = 15000 }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60000 }, + }; + await cfg.Validate(ApplicationType.Client); + cfg.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var instance = new ApplicationInstance { ApplicationConfiguration = cfg, ApplicationType = ApplicationType.Client }; + await instance.CheckApplicationInstanceCertificate(true, CertificateFactory.DefaultKeySize); + + var selected = CoreClientUtils.SelectEndpoint(cfg, _endpoint, useSecurity: false); + var endpointConfig = EndpointConfiguration.Create(cfg); + var configuredEndpoint = new ConfiguredEndpoint(null, selected, endpointConfig); + + return await Session.Create(cfg, configuredEndpoint, false, "OtOpcUaHistoryTestClientSession", 60000, + new UserIdentity(new AnonymousIdentityToken()), null); + } + + /// + /// Stub driver that implements so the service dispatch + /// can be verified without bringing up a real Galaxy or Historian. Captures the last- + /// seen arguments so tests can assert what the service handler forwarded. + /// + private sealed class HistoryDriver : IDriver, ITagDiscovery, IReadable, IHistoryProvider + { + public string DriverInstanceId => "history-driver"; + public string DriverType => "HistoryStub"; + + public int RawSamplesReturned => 3; + public int FirstRawValue => 100; + public int EventsReturned => 2; + + public HistoryAggregateType? LastProcessedAggregate { get; private set; } + public TimeSpan? LastProcessedInterval { get; private set; } + public IReadOnlyList? LastAtTimeRequestedTimes { get; private set; } + + public Task InitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ReinitializeAsync(string driverConfigJson, CancellationToken ct) => Task.CompletedTask; + public Task ShutdownAsync(CancellationToken ct) => Task.CompletedTask; + public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null); + public long GetMemoryFootprint() => 0; + public Task FlushOptionalCachesAsync(CancellationToken ct) => Task.CompletedTask; + + public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken ct) + { + // Every variable must be Historized for HistoryRead to route — the node-manager's + // stack base class checks the bit before dispatching. + builder.Variable("raw", "raw", + new DriverAttributeInfo("raw.var", DriverDataType.Int32, false, null, + SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false)); + builder.Variable("proc", "proc", + new DriverAttributeInfo("proc.var", DriverDataType.Float64, false, null, + SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false)); + builder.Variable("atTime", "atTime", + new DriverAttributeInfo("atTime.var", DriverDataType.Int32, false, null, + SecurityClassification.FreeAccess, IsHistorized: true, IsAlarm: false)); + return Task.CompletedTask; + } + + public Task> ReadAsync( + IReadOnlyList fullReferences, CancellationToken cancellationToken) + { + var now = DateTime.UtcNow; + IReadOnlyList r = + [.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))]; + return Task.FromResult(r); + } + + public Task ReadRawAsync( + string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode, + CancellationToken cancellationToken) + { + var samples = new List(); + for (var i = 0; i < RawSamplesReturned; i++) + { + samples.Add(new DataValueSnapshot( + Value: FirstRawValue + i, + StatusCode: StatusCodes.Good, + SourceTimestampUtc: startUtc.AddSeconds(i), + ServerTimestampUtc: startUtc.AddSeconds(i))); + } + return Task.FromResult(new DriverHistoryReadResult(samples, null)); + } + + public Task ReadProcessedAsync( + string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval, + HistoryAggregateType aggregate, CancellationToken cancellationToken) + { + LastProcessedAggregate = aggregate; + LastProcessedInterval = interval; + return Task.FromResult(new DriverHistoryReadResult( + [new DataValueSnapshot(1.0, StatusCodes.Good, startUtc, startUtc)], + null)); + } + + public Task ReadAtTimeAsync( + string fullReference, IReadOnlyList timestampsUtc, + CancellationToken cancellationToken) + { + LastAtTimeRequestedTimes = timestampsUtc; + var samples = timestampsUtc + .Select(t => new DataValueSnapshot(42, StatusCodes.Good, t, t)) + .ToArray(); + return Task.FromResult(new DriverHistoryReadResult(samples, null)); + } + + public Task ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, + CancellationToken cancellationToken) + { + var events = new List(); + for (var i = 0; i < EventsReturned; i++) + { + events.Add(new HistoricalEvent( + EventId: $"e{i}", + SourceName: sourceName, + EventTimeUtc: startUtc.AddHours(i), + ReceivedTimeUtc: startUtc.AddHours(i).AddSeconds(1), + Message: $"Event {i}", + Severity: (ushort)(500 + i))); + } + return Task.FromResult(new HistoricalEventsResult(events, null)); + } + } +} -- 2.49.1