Phase 3 PR 38 — DriverNodeManager HistoryRead override (LMX #1 finish) #37

Merged
dohertj2 merged 1 commits from phase-3-pr38-historyread-servicehandler into v2 2026-04-18 17:53:26 -04:00
Owner

Last missing piece of LMX #1. Wires the OPC UA HistoryRead service through CustomNodeManager2's four protected per-kind hooks so clients can actually reach the IHistoryProvider capability we exposed in PR 35.

What changes

Overrides HistoryReadRawModified / HistoryReadProcessed / HistoryReadAtTime / HistoryReadEvents on DriverNodeManager. Each hook:

  1. Iterates nodesToProcess (the filtered subset the stack assigned to this manager).
  2. Resolves handle.NodeId.Identifier → driver-side full reference.
  3. Dispatches to the matching IHistoryProvider method.
  4. Writes the outer results[handle.Index] AND errors[handle.Index] via a WriteResult helper.

Hard-won gotchas (documented inline)

  1. handle.Index, not the loop counternodesToProcess is filtered from nodesToRead. Writing to results[n] lands in the wrong slot for mixed-manager batches.
  2. Both results[i] AND errors[i] must be setMasterNodeManager merges them, and leaving errors[i] at its default (BadHistoryOperationUnsupported) overrides a Good results[i] on the wire. Cost most of the debug budget. Helpers WriteResult / WriteUnsupported / WriteInternalError / WriteNodeIdUnknown always touch both.
  3. AccessLevels.HistoryRead on variables — the stack's early-gate check (variable.AccessLevel & HistoryRead != 0) rejects requests before our override runs. Historized variables now set the bit in AccessLevel + UserAccessLevel.
  4. EventNotifiers.HistoryRead | SubscribeToEvents on the driver root — required for HistoryReadEvents to target the folder (the conventional pattern for alarm-history browse).
  5. OpcHistoryReadResult aliasOpc.Ua.HistoryReadResult (service-layer) collides with Core.Abstractions.HistoryReadResult (driver-side) by type name; explicit alias keeps both overrides and test stubs unambiguous.

Aggregate + event shape

  • MapAggregate translates ObjectIds.AggregateFunction_{Average|Minimum|Maximum|Total|Count} to the driver enum; returns null for anything else so the handler surfaces BadAggregateNotSupported at the batch level (per Part 13 — ReadProcessedDetails carries one aggregate for all nodes).
  • BuildHistoryData wraps driver samples as Opc.Ua.HistoryData in an ExtensionObject.
  • BuildHistoryEvent emits a HistoryEvent with the canonical BaseEventType field list (EventId / SourceName / Message / Severity / Time / ReceiveTime) — the order clients with default SimpleAttributeOperands expect. Per-SelectClause evaluation is a follow-up.

Tests

DriverNodeManagerHistoryMappingTests — 12 unit cases pinning MapAggregate, BuildHistoryData, BuildHistoryEvent, ToDataValue. Reflection-backed theory for aggregates guards against the stack renaming AggregateFunction_*. Null-preservation + DateTimeKind.Utc + canonical field-list ordering pinned as regression guards.

HistoryReadIntegrationTests — 5 end-to-end cases drive a real Session.HistoryRead against a fake IHistoryProvider driver through the running stack:

  • Raw round-trip (per-node DataValue ordering + values).
  • Processed with Average aggregate (captures driver-received aggregate + interval).
  • Unsupported aggregate (TimeAverageBadAggregateNotSupported).
  • At-time (forwards the per-timestamp list).
  • Events (BaseEventType field-list shape, SelectClauses populated to satisfy the stack's filter validator).

Test posture

  • 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.

Deferred (explicit in lmx-followups.md)

  • Continuation-point plumbing via Session.Save/RestoreHistoryContinuationPoint. Driver returns null continuations today so pass-through works.
  • Per-SelectClause evaluation for HistoryReadEvents. Clients with custom field selections currently get the canonical layout.
Last missing piece of LMX #1. Wires the OPC UA HistoryRead service through `CustomNodeManager2`'s four protected per-kind hooks so clients can actually reach the `IHistoryProvider` capability we exposed in PR 35. ## What changes Overrides `HistoryReadRawModified` / `HistoryReadProcessed` / `HistoryReadAtTime` / `HistoryReadEvents` on `DriverNodeManager`. Each hook: 1. Iterates `nodesToProcess` (the filtered subset the stack assigned to this manager). 2. Resolves `handle.NodeId.Identifier` → driver-side full reference. 3. Dispatches to the matching `IHistoryProvider` method. 4. Writes the outer `results[handle.Index]` AND `errors[handle.Index]` via a `WriteResult` helper. ## Hard-won gotchas (documented inline) 1. **`handle.Index`, not the loop counter** — `nodesToProcess` is filtered from `nodesToRead`. Writing to `results[n]` lands in the wrong slot for mixed-manager batches. 2. **Both `results[i]` AND `errors[i]` must be set** — `MasterNodeManager` merges them, and leaving `errors[i]` at its default (`BadHistoryOperationUnsupported`) overrides a Good `results[i]` on the wire. Cost most of the debug budget. Helpers `WriteResult` / `WriteUnsupported` / `WriteInternalError` / `WriteNodeIdUnknown` always touch both. 3. **`AccessLevels.HistoryRead` on variables** — the stack's early-gate check (`variable.AccessLevel & HistoryRead != 0`) rejects requests before our override runs. Historized variables now set the bit in `AccessLevel` + `UserAccessLevel`. 4. **`EventNotifiers.HistoryRead | SubscribeToEvents` on the driver root** — required for `HistoryReadEvents` to target the folder (the conventional pattern for alarm-history browse). 5. **`OpcHistoryReadResult` alias** — `Opc.Ua.HistoryReadResult` (service-layer) collides with `Core.Abstractions.HistoryReadResult` (driver-side) by type name; explicit alias keeps both overrides and test stubs unambiguous. ## Aggregate + event shape - `MapAggregate` translates `ObjectIds.AggregateFunction_{Average|Minimum|Maximum|Total|Count}` to the driver enum; returns null for anything else so the handler surfaces `BadAggregateNotSupported` at the batch level (per Part 13 — `ReadProcessedDetails` carries one aggregate for all nodes). - `BuildHistoryData` wraps driver samples as `Opc.Ua.HistoryData` in an `ExtensionObject`. - `BuildHistoryEvent` emits a `HistoryEvent` with the canonical BaseEventType field list (EventId / SourceName / Message / Severity / Time / ReceiveTime) — the order clients with default `SimpleAttributeOperand`s expect. Per-`SelectClause` evaluation is a follow-up. ## Tests `DriverNodeManagerHistoryMappingTests` — 12 unit cases pinning `MapAggregate`, `BuildHistoryData`, `BuildHistoryEvent`, `ToDataValue`. Reflection-backed theory for aggregates guards against the stack renaming `AggregateFunction_*`. Null-preservation + `DateTimeKind.Utc` + canonical field-list ordering pinned as regression guards. `HistoryReadIntegrationTests` — 5 end-to-end cases drive a real `Session.HistoryRead` against a fake `IHistoryProvider` driver through the running stack: - Raw round-trip (per-node DataValue ordering + values). - Processed with `Average` aggregate (captures driver-received aggregate + interval). - 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). ## Test posture - 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. ## Deferred (explicit in `lmx-followups.md`) - Continuation-point plumbing via `Session.Save/RestoreHistoryContinuationPoint`. Driver returns null continuations today so pass-through works. - Per-`SelectClause` evaluation for `HistoryReadEvents`. Clients with custom field selections currently get the canonical layout.
dohertj2 added 1 commit 2026-04-18 17:53:23 -04:00
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>
dohertj2 merged commit e4885aadd0 into v2 2026-04-18 17:53:26 -04:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: dohertj2/lmxopcua#37