Phase 3 PR 38 — DriverNodeManager HistoryRead override (LMX #1 finish). Wires the OPC UA HistoryRead service through CustomNodeManager2's four protected per-kind hooks — HistoryReadRawModified / HistoryReadProcessed / HistoryReadAtTime / HistoryReadEvents — each dispatching to the driver's IHistoryProvider capability (PR 35 for ReadAtTime + ReadEvents on top of PR 19-era ReadRaw + ReadProcessed). Was the last missing piece of the end-to-end HistoryRead path: PR 10 + PR 11 shipped the Galaxy.Host IPC contracts, PR 35 surfaced them on IHistoryProvider + GalaxyProxyDriver, but no server-side handler bridged OPC UA HistoryRead service requests onto the capability interface. Now it does.

Per-kind override shape: each hook receives the pre-filtered nodesToProcess list (NodeHandles for nodes this manager claimed), iterates them, resolves handle.NodeId.Identifier to the driver-side full reference string, and dispatches to the right IHistoryProvider method. Write back into the outer results + errors slots at handle.Index (not the local loop counter — nodesToProcess is a filtered subset of nodesToRead, so indexing by the loop counter lands in the wrong slot for mixed-manager batches). WriteResult helper sets both results[i] AND errors[i]; this matters because MasterNodeManager merges them and leaving errors[i] at its default (BadHistoryOperationUnsupported) overrides a Good result with Unsupported on the wire — this was the subtle failure mode that masked a correctly-constructed HistoryData response during debugging. Failure-isolation per node: NotSupportedException from a driver that doesn't implement a particular HistoryProvider method translates to BadHistoryOperationUnsupported in that slot; generic exceptions log and surface BadInternalError; unresolvable NodeIds get BadNodeIdUnknown. The batch continues unconditionally.
Aggregate mapping: MapAggregate translates ObjectIds.AggregateFunction_Average / Minimum / Maximum / Total / Count to the driver's HistoryAggregateType enum. Null for anything else (e.g. TimeAverage, Interpolative) so the handler surfaces BadAggregateNotSupported at the batch level — per Part 13, one unsupported aggregate means the whole request fails since ReadProcessedDetails carries one aggregate list for all nodes. BuildHistoryData wraps driver DataValueSnapshots as Opc.Ua.HistoryData in an ExtensionObject; BuildHistoryEvent wraps HistoricalEvents as Opc.Ua.HistoryEvent with the canonical BaseEventType field list (EventId, SourceName, Message, Severity, Time, ReceiveTime — the order OPC UA clients that didn't customize the SelectClause expect). ToDataValue preserves null SourceTimestamp (Galaxy historian rows often carry only ServerTimestamp) — synthesizing a SourceTimestamp would lie about actual sample time.
Two address-space changes were required to make the stack dispatch reach the per-kind hooks at all: (1) historized variables get AccessLevels.HistoryRead added to their AccessLevel byte — the base's early-gate check on (variable.AccessLevel & HistoryRead != 0) was rejecting requests before our override ever ran; (2) the driver-root folder gets EventNotifiers.HistoryRead | SubscribeToEvents so HistoryReadEvents can target it (the conventional pattern for alarm-history browse against a driver-owned object). Document the 'set both bits' requirement inline since it's not obvious from the surface API.
OpcHistoryReadResult alias: Opc.Ua.HistoryReadResult (service-layer per-node result) collides with Core.Abstractions.HistoryReadResult (driver-side samples + continuation point) by type name; the alias 'using OpcHistoryReadResult = Opc.Ua.HistoryReadResult' keeps the override signatures unambiguous and the test project applies the mirror pattern for its stub driver impl.
Tests — DriverNodeManagerHistoryMappingTests (12 new Category=Unit cases): MapAggregate translates each supported aggregate NodeId via reflection-backed theory (guards against the stack renaming AggregateFunction_* constants); returns null for unsupported NodeIds (TimeAverage) and null input; BuildHistoryData wraps samples with correct DataValues + SourceTimestamp preservation; BuildHistoryEvent emits the 6-element BaseEventType field list in canonical order (regression guard for a future 'respect the client's SelectClauses' change); null SourceName / Message translate to empty-string Variants (nullable-Variant refactor trap); ToDataValue preserves StatusCode + both timestamps; ToDataValue leaves SourceTimestamp at default when the snapshot omits it. HistoryReadIntegrationTests (5 new Category=Integration): drives a real OPC UA client Session.HistoryRead against a fake HistoryDriver through the running server. Covers raw round-trip (verifies per-node DataValue ordering + values); processed with Average aggregate (captures the driver's received aggregate + interval, asserting MapAggregate routed correctly); unsupported aggregate (TimeAverage → BadAggregateNotSupported); at-time (forwards the per-timestamp list); events (BaseEventType field list shape, SelectClauses populated to satisfy the stack's filter validator). Server.Tests Unit: 55 pass / 0 fail (43 prior + 12 new mapping). Server.Tests Integration: 14 pass / 0 fail (9 prior + 5 new history). Full solution build clean, 0 errors.
lmx-followups.md #1 updated to 'DONE (PRs 35 + 38)' with two explicit deferred items: continuation-point plumbing (driver returns null today so pass-through is fine) and per-SelectClause evaluation in HistoryReadEvents (clients with custom field selections get the canonical BaseEventType layout today).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-04-18 17:50:23 -04:00
parent 19bcf20fbe
commit 52a29100b1
4 changed files with 952 additions and 17 deletions

View File

@@ -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<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> cache)
{
if (History is null)
{
MarkAllUnsupported(nodesToProcess, results, errors);
return;
}
var requestedTimes = (IReadOnlyList<DateTime>)(details.ReqTimes?.ToArray() ?? Array.Empty<DateTime>());
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<HistoryReadValueId> nodesToRead, IList<OpcHistoryReadResult> results,
IList<ServiceResult> errors, List<NodeHandle> nodesToProcess,
IDictionary<NodeId, NodeState> 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<OpcHistoryReadResult> results, IList<ServiceResult> 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<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
{
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadHistoryOperationUnsupported };
errors[i] = StatusCodes.BadHistoryOperationUnsupported;
}
private static void WriteInternalError(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
{
results[i] = new OpcHistoryReadResult { StatusCode = StatusCodes.BadInternalError };
errors[i] = StatusCodes.BadInternalError;
}
private static void WriteNodeIdUnknown(IList<OpcHistoryReadResult> results, IList<ServiceResult> errors, int i)
{
WriteNodeIdUnknown(results, errors, i);
errors[i] = StatusCodes.BadNodeIdUnknown;
}
private static void MarkAllUnsupported(
List<NodeHandle> nodes, IList<OpcHistoryReadResult> results, IList<ServiceResult> 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);
}
}
/// <summary>
/// Map the OPC UA Part 13 aggregate-function NodeId to the driver's
/// <see cref="HistoryAggregateType"/>. Internal so the test suite can pin the mapping
/// without exposing public API. Returns null for unsupported aggregates so the service
/// handler can surface <c>BadAggregateNotSupported</c> on the whole batch.
/// </summary>
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;
}
/// <summary>
/// Wrap driver samples as <c>HistoryData</c> in an <c>ExtensionObject</c> — the on-wire
/// shape the OPC UA HistoryRead service expects for raw / processed / at-time reads.
/// </summary>
internal static ExtensionObject BuildHistoryData(IReadOnlyList<DataValueSnapshot> samples)
{
var values = new DataValueCollection(samples.Count);
foreach (var s in samples) values.Add(ToDataValue(s));
return new ExtensionObject(new HistoryData { DataValues = values });
}
/// <summary>
/// Wrap driver events as <c>HistoryEvent</c> in an <c>ExtensionObject</c>. Populates
/// the minimum BaseEventType field set (SourceName, Message, Severity, Time,
/// ReceiveTime, EventId) so clients that request the default
/// <c>SimpleAttributeOperand</c> 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.
/// </summary>
internal static ExtensionObject BuildHistoryEvent(IReadOnlyList<HistoricalEvent> 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;
}
}