Compare commits

...

4 Commits

Author SHA1 Message Date
Joseph Doherty
52a29100b1 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>
2026-04-18 17:50:23 -04:00
19bcf20fbe Merge pull request 'Phase 3 PR 37 — End-to-end live-stack Galaxy smoke test' (#36) from phase-3-pr37-live-stack-smoke into v2 2026-04-18 16:56:50 -04:00
Joseph Doherty
8adc8f5ab8 Phase 3 PR 37 — End-to-end live-stack Galaxy smoke test. Closes the code side of LMX follow-up #5; once OtOpcUaGalaxyHost is installed + started on the dev box, the suite exercises the full topology GalaxyProxyDriver in-process → named-pipe IPC → running OtOpcUaGalaxyHost Windows service → MxAccessGalaxyBackend → live MXAccess runtime → real deployed Galaxy objects. Never spawns the Host process itself — connects to the already-running service per project_galaxy_host_service.md, which is the only way to exercise the production COM-apartment + service-account + pipe-ACL configuration.
LiveStackConfig resolves the pipe name + per-install shared secret from two sources in order: OTOPCUA_GALAXY_PIPE + OTOPCUA_GALAXY_SECRET env vars first (for CI / benchwork overrides), then the service's per-process Environment registry values under HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost (what Install-Services.ps1 writes at install time). Registry read requires the test host to run elevated on most boxes — the skip message says so explicitly so operators see the right remediation. Hard-coded secrets are deliberately avoided: the installer generates 32 fresh random bytes per install, a committed secret would diverge from production the moment the service is re-installed.
LiveStackFixture is an IAsyncLifetime that (1) runs AvevaPrerequisites.CheckAllAsync with CheckGalaxyHostPipe=true + CheckHistorian=false — produces a structured PrerequisiteReport whose SkipReason is the exact operator-facing 'here's what you need to fix' text, (2) resolves LiveStackConfig and surfaces a clear skip when the secret isn't discoverable, (3) instantiates GalaxyProxyDriver + calls InitializeAsync (the IPC handshake), capturing a skip with the exception detail + common-cause hints (secret mismatch, SID not in pipe ACL, Host's backend couldn't connect to ZB) rather than letting a NullRef cascade through every subsequent test. SkipIfUnavailable() translates the captured SkipReason into Assert.Skip at the top of every fact so tests read as cleanly-skipped with a visible reason, not silently-passed or crashed.
LiveStackSmokeTests (5 facts, Collection=LiveStack, Category=LiveGalaxy): Fixture_initialized_successfully (cheapest possible end-to-end assertion — if this passes, the IPC handshake worked); Driver_reports_Healthy_after_IPC_handshake (DriverHealth.State post-connect); DiscoverAsync_returns_at_least_one_variable_from_live_galaxy (captures every Variable() call from DiscoverAsync via CapturingAddressSpaceBuilder and asserts > 0 — zero here usually means the Host couldn't read ZB, the skip message names OTOPCUA_GALAXY_ZB_CONN to check); GetHostStatuses_reports_at_least_one_platform (IHostConnectivityProbe surface — zero means the probe loop hasn't fired or no Platform is deployed locally); Can_read_a_discovered_variable_from_live_galaxy (reads the first discovered attribute's full reference, asserts status != BadInternalError — Galaxy's Uncertain-quality-until-first-Engine-scan is intentionally NOT treated as failure since it depends on runtime state that varies across test runs). Read-only by design; writes need an agreed scratch tag to avoid mutating a process-critical attribute — deferred to a follow-up PR that reuses this fixture.
CapturingAddressSpaceBuilder is a minimal IAddressSpaceBuilder that flattens every Variable() call into a list so tests can inspect what discovery produced without booting the full OPC UA node-manager stack; alarm annotation + property calls are no-ops. Scoped private to the test class.
Galaxy.Proxy.Tests csproj gains a ProjectReference to Driver.Galaxy.TestSupport (PR 36) for AvevaPrerequisites. The NU1702 warning about the Host project being net48-referenced-by-net10 is pre-existing from the HostSubprocessParityTests — Proxy.Tests only needs the Host EXE path for that parity scenario, not type surface.
Test run on THIS machine (OtOpcUaGalaxyHost not yet installed): Skipped! Failed 0, Passed 0, Skipped 5 — each skip message includes the full prerequisites report pointing at the missing service. Once the service is installed + started (scripts\install\Install-Services.ps1), the 5 facts will execute against live Galaxy. Proxy.Tests Unit: 17 pass / 0 fail (unchanged — new tests are Category=LiveGalaxy, separate suite). Full Proxy build clean. Memory already captures the 'live tests run via already-running service, don't spawn' convention (project_galaxy_host_service.md).
lmx-followups.md #5 updated: status is 'IN PROGRESS' across PRs 36 + 37 with the explicit remaining work (install + start services, subscribe-and-receive, write round-trip).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 16:49:51 -04:00
261869d84e Merge pull request 'Phase 3 PR 36 — AVEVA prerequisites test-support library' (#35) from phase-3-pr36-aveva-prerequisites into v2 2026-04-18 16:44:41 -04:00
8 changed files with 1323 additions and 27 deletions

View File

@@ -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)**
@@ -77,18 +103,36 @@ drive a full OPC UA session with username/password, then read an
`IHostConnectivityProbe`-style "whoami" node to verify the role surfaced).
That needs a test-only address-space node and is a separate PR.
## 5. Full Galaxy live-service smoke test against the merged v2 stack
## 5. Full Galaxy live-service smoke test against the merged v2 stack — **IN PROGRESS (PRs 36 + 37)**
**Status**: Individual pieces have live smoke tests (PR 5 MXAccess, PR 13
probe manager, PR 14 alarm tracker), but the full loop — OPC UA client →
`OtOpcUaServer``GalaxyProxyDriver` (in-process) → named-pipe to
Galaxy.Host subprocess → live MXAccess runtime → real Galaxy objects — has
no single end-to-end smoke test.
PR 36 shipped the prerequisites helper (`AvevaPrerequisites`) that probes
every dependency a live smoke test needs and produces actionable skip
messages.
**To do**:
- Test that spawns the full topology, discovers a deployed Galaxy object,
subscribes to one of its attributes, writes a value back, and asserts the
write round-tripped through MXAccess. Skip when ArchestrA isn't running.
PR 37 shipped the live-stack smoke test project structure:
`tests/Driver.Galaxy.Proxy.Tests/LiveStack/` with `LiveStackFixture` (connects
to the *already-running* `OtOpcUaGalaxyHost` Windows service via named pipe;
never spawns the Host process) and `LiveStackSmokeTests` covering:
- Fixture initializes successfully (IPC handshake succeeds end-to-end).
- Driver reports `DriverState.Healthy` post-handshake.
- `DiscoverAsync` returns at least one variable from the live Galaxy.
- `GetHostStatuses` reports at least one Platform/AppEngine host.
- `ReadAsync` on a discovered variable round-trips through
Proxy → Host pipe → MXAccess → back without a BadInternalError.
Shared secret + pipe name resolve from `OTOPCUA_GALAXY_SECRET` /
`OTOPCUA_GALAXY_PIPE` env vars, falling back to reading the service's
registry-stored Environment values (requires elevated test host).
**Remaining**:
- Install + run the `OtOpcUaGalaxyHost` + `OtOpcUa` services on the dev box
(`scripts/install/Install-Services.ps1`) so the skip-on-unready tests
actually execute and the smoke PR lands green.
- Subscribe-and-receive-data-change fact (needs a known tag that actually
ticks; deferred until operators confirm a scratch tag exists).
- Write-and-roundtrip fact (needs a test-only UDA or agreed scratch tag
so we can't accidentally mutate a process-critical value).
## 6. Second driver instance on the same server — **DONE (PR 32)**

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;
}
}

View File

@@ -0,0 +1,75 @@
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using Microsoft.Win32;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// Resolves the pipe name + shared secret the live <see cref="GalaxyProxyDriver"/> needs
/// to connect to a running <c>OtOpcUaGalaxyHost</c> Windows service. Two sources are
/// consulted, first match wins:
/// <list type="number">
/// <item>Explicit env vars (<c>OTOPCUA_GALAXY_PIPE</c>, <c>OTOPCUA_GALAXY_SECRET</c>) — lets CI / benchwork override.</item>
/// <item>The service's per-process <c>Environment</c> registry values under
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c> — what
/// <c>Install-Services.ps1</c> writes at install time. Requires the test to run as a
/// principal with read access to that registry key (typically Administrators).</item>
/// </list>
/// </summary>
/// <remarks>
/// Explicitly NOT baked-in-to-source: the shared secret is rotated per install (the
/// installer generates 32 random bytes and stores the base64 string). A hard-coded secret
/// in tests would diverge from production the moment someone re-installed the service.
/// </remarks>
public sealed record LiveStackConfig(string PipeName, string SharedSecret, string? Source)
{
public const string EnvPipeName = "OTOPCUA_GALAXY_PIPE";
public const string EnvSharedSecret = "OTOPCUA_GALAXY_SECRET";
public const string ServiceRegistryKey =
@"SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost";
public const string DefaultPipeName = "OtOpcUaGalaxy";
public static LiveStackConfig? Resolve()
{
var envPipe = Environment.GetEnvironmentVariable(EnvPipeName);
var envSecret = Environment.GetEnvironmentVariable(EnvSharedSecret);
if (!string.IsNullOrWhiteSpace(envPipe) && !string.IsNullOrWhiteSpace(envSecret))
return new LiveStackConfig(envPipe, envSecret, "env vars");
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return null;
return FromServiceRegistry();
}
[SupportedOSPlatform("windows")]
private static LiveStackConfig? FromServiceRegistry()
{
try
{
using var key = Registry.LocalMachine.OpenSubKey(ServiceRegistryKey);
if (key is null) return null;
var env = key.GetValue("Environment") as string[];
if (env is null || env.Length == 0) return null;
string? pipe = null, secret = null;
foreach (var line in env)
{
var eq = line.IndexOf('=');
if (eq <= 0) continue;
var name = line[..eq];
var value = line[(eq + 1)..];
if (name.Equals(EnvPipeName, StringComparison.OrdinalIgnoreCase)) pipe = value;
else if (name.Equals(EnvSharedSecret, StringComparison.OrdinalIgnoreCase)) secret = value;
}
if (string.IsNullOrWhiteSpace(secret)) return null;
return new LiveStackConfig(pipe ?? DefaultPipeName, secret, "service registry");
}
catch
{
// Access denied / key missing / malformed — caller gets null and surfaces a Skip.
return null;
}
}
}

View File

@@ -0,0 +1,120 @@
using System.Threading;
using System.Threading.Tasks;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// Connects a single <see cref="GalaxyProxyDriver"/> to the already-running
/// <c>OtOpcUaGalaxyHost</c> Windows service for the lifetime of a test class. Uses
/// <see cref="AvevaPrerequisites"/> to decide whether to proceed; on failure,
/// <see cref="SkipReason"/> is populated and each test calls <see cref="SkipIfUnavailable"/>
/// to translate that into <c>Assert.Skip</c>.
/// </summary>
/// <remarks>
/// <para>
/// <b>Does NOT spawn the Host process.</b> Production deploys <c>OtOpcUaGalaxyHost</c>
/// as a standalone Windows service — spawning a second instance from a test would
/// bypass the COM-apartment + service-account setup and fail differently than
/// production (see <c>project_galaxy_host_service.md</c> memory).
/// </para>
/// <para>
/// <b>Shared-secret handling</b>: read from <see cref="LiveStackConfig"/> — env vars
/// first, then the service's registry-stored <c>Environment</c> values. Requires
/// the test process to have read access to
/// <c>HKLM\SYSTEM\CurrentControlSet\Services\OtOpcUaGalaxyHost</c>; on a dev box
/// that typically means running the test host elevated, or exporting
/// <c>OTOPCUA_GALAXY_SECRET</c> out-of-band.
/// </para>
/// </remarks>
public sealed class LiveStackFixture : IAsyncLifetime
{
public GalaxyProxyDriver? Driver { get; private set; }
public string? SkipReason { get; private set; }
public PrerequisiteReport? PrerequisiteReport { get; private set; }
public LiveStackConfig? Config { get; private set; }
public async ValueTask InitializeAsync()
{
// 1. AVEVA + OtOpcUa service state — actionable diagnostic if anything is missing.
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10));
PrerequisiteReport = await AvevaPrerequisites.CheckAllAsync(
new AvevaPrerequisites.Options { CheckGalaxyHostPipe = true, CheckHistorian = false },
cts.Token);
if (!PrerequisiteReport.IsLivetestReady)
{
SkipReason = PrerequisiteReport.SkipReason;
return;
}
// 2. Secret / pipe-name resolution. If the service is running but we can't discover its
// env vars from registry (non-elevated test host), a clear message beats a silent
// connect-rejected failure 10 seconds later.
Config = LiveStackConfig.Resolve();
if (Config is null)
{
SkipReason =
$"Cannot resolve shared secret. Set {LiveStackConfig.EnvSharedSecret} (and optionally " +
$"{LiveStackConfig.EnvPipeName}) in the environment, or run the test host elevated so it " +
$"can read HKLM\\{LiveStackConfig.ServiceRegistryKey}\\Environment.";
return;
}
// 3. Connect. InitializeAsync does the pipe connect + handshake; a 5-second
// ConnectTimeout gives enough headroom for a service that just started.
Driver = new GalaxyProxyDriver(new GalaxyProxyOptions
{
DriverInstanceId = "live-stack-smoke",
PipeName = Config.PipeName,
SharedSecret = Config.SharedSecret,
ConnectTimeout = TimeSpan.FromSeconds(5),
});
try
{
await Driver.InitializeAsync(driverConfigJson: "{}", CancellationToken.None);
}
catch (Exception ex)
{
SkipReason =
$"Connected to named pipe '{Config.PipeName}' but GalaxyProxyDriver.InitializeAsync failed: " +
$"{ex.GetType().Name}: {ex.Message}. Common causes: shared secret mismatch (rotated after last install), " +
$"service account SID not in pipe ACL (installer sets OTOPCUA_ALLOWED_SID to the service account — " +
$"test must run as that user), or Host's backend couldn't connect to ZB.";
Driver.Dispose();
Driver = null;
return;
}
}
public async ValueTask DisposeAsync()
{
if (Driver is not null)
{
try { await Driver.ShutdownAsync(CancellationToken.None); } catch { /* best-effort */ }
Driver.Dispose();
}
}
/// <summary>
/// Translate <see cref="SkipReason"/> into <c>Assert.Skip</c>. Tests call this at the
/// top of every fact so a fixture init failure shows up as a cleanly-skipped test with
/// the full prerequisites report, not a cascading NullReferenceException on
/// <see cref="Driver"/>.
/// </summary>
public void SkipIfUnavailable()
{
if (SkipReason is not null) Assert.Skip(SkipReason);
}
}
[CollectionDefinition(Name)]
public sealed class LiveStackCollection : ICollectionFixture<LiveStackFixture>
{
public const string Name = "LiveStack";
}

View File

@@ -0,0 +1,147 @@
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.Tests.LiveStack;
/// <summary>
/// End-to-end smoke against the installed <c>OtOpcUaGalaxyHost</c> Windows service.
/// Closes LMX follow-up #5 — exercises the full topology: <see cref="GalaxyProxyDriver"/>
/// in-process → named-pipe IPC → <c>OtOpcUaGalaxyHost</c> service → <c>MxAccessGalaxyBackend</c> →
/// live MXAccess runtime → real Galaxy objects + attributes.
/// </summary>
/// <remarks>
/// <para>
/// <b>Preconditions</b> (all checked by <see cref="LiveStackFixture"/>, surfaced via
/// <c>Assert.Skip</c> when missing):
/// </para>
/// <list type="bullet">
/// <item>AVEVA System Platform installed + Platform deployed.</item>
/// <item><c>aaBootstrap</c> / <c>aaGR</c> / <c>NmxSvc</c> / <c>MSSQLSERVER</c> running.</item>
/// <item>MXAccess COM server registered.</item>
/// <item>ZB database exists with at least one deployed gobject.</item>
/// <item><c>OtOpcUaGalaxyHost</c> service installed + running (named pipe accepting connections).</item>
/// <item>Shared secret discoverable via <c>OTOPCUA_GALAXY_SECRET</c> env var or the
/// service's registry Environment values (test host typically needs to be elevated
/// to read the latter).</item>
/// <item>Test process runs as the account listed in the service's pipe ACL
/// (<c>OTOPCUA_ALLOWED_SID</c>, typically the service account per decision #76).</item>
/// </list>
/// <para>
/// Tests here are deliberately read-only. Writes against live Galaxy attributes are a
/// separate concern — they need a test-only UDA or an agreed scratch tag so they can't
/// accidentally mutate a process-critical value. Adding a write test is a follow-up
/// PR that reuses this fixture.
/// </para>
/// </remarks>
[Trait("Category", "LiveGalaxy")]
[Collection(LiveStackCollection.Name)]
public sealed class LiveStackSmokeTests(LiveStackFixture fixture)
{
[Fact]
public void Fixture_initialized_successfully()
{
fixture.SkipIfUnavailable();
// If the fixture init succeeded, Driver is non-null and InitializeAsync completed.
// This is the cheapest possible assertion that the IPC handshake worked end-to-end;
// every other test in this class depends on it.
fixture.Driver.ShouldNotBeNull();
fixture.Config.ShouldNotBeNull();
fixture.PrerequisiteReport.ShouldNotBeNull();
fixture.PrerequisiteReport!.IsLivetestReady.ShouldBeTrue(fixture.PrerequisiteReport.SkipReason);
}
[Fact]
public void Driver_reports_Healthy_after_IPC_handshake()
{
fixture.SkipIfUnavailable();
var health = fixture.Driver!.GetHealth();
health.State.ShouldBe(DriverState.Healthy,
$"Expected Healthy after successful IPC connect; Reason={health.LastError}");
}
[Fact]
public async Task DiscoverAsync_returns_at_least_one_variable_from_live_galaxy()
{
fixture.SkipIfUnavailable();
var builder = new CapturingAddressSpaceBuilder();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
builder.Variables.Count.ShouldBeGreaterThan(0,
"Live Galaxy has > 0 deployed objects per the prereq check — at least one variable must be discovered. " +
"Zero usually means the Host couldn't read ZB (check OTOPCUA_GALAXY_ZB_CONN in the service Environment).");
// Every discovered attribute must carry a non-empty FullName so the OPC UA server can
// route reads/writes back. Regression guard — PR 19 normalized this across drivers.
builder.Variables.ShouldAllBe(v => !string.IsNullOrEmpty(v.AttributeInfo.FullName));
}
[Fact]
public void GetHostStatuses_reports_at_least_one_platform()
{
fixture.SkipIfUnavailable();
var statuses = fixture.Driver!.GetHostStatuses();
statuses.Count.ShouldBeGreaterThan(0,
"Live Galaxy must report at least one Platform/AppEngine host via IHostConnectivityProbe. " +
"Zero means the Host's probe loop hasn't completed its first tick or the Platform isn't deployed locally.");
// Host names are driver-opaque to the Core but non-empty by contract.
statuses.ShouldAllBe(h => !string.IsNullOrEmpty(h.HostName));
}
[Fact]
public async Task Can_read_a_discovered_variable_from_live_galaxy()
{
fixture.SkipIfUnavailable();
var builder = new CapturingAddressSpaceBuilder();
using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
await fixture.Driver!.DiscoverAsync(builder, cts.Token);
builder.Variables.Count.ShouldBeGreaterThan(0);
// Pick the first discovered variable. Read-only smoke — we don't assert on Value,
// only that a ReadAsync round-trip through Proxy → Host pipe → MXAccess → back
// returns a snapshot with a non-BadInternalError status. Galaxy attributes default to
// Uncertain quality until the Engine's first scan publishes them, which is fine here.
var full = builder.Variables[0].AttributeInfo.FullName;
var snapshots = await fixture.Driver!.ReadAsync([full], cts.Token);
snapshots.Count.ShouldBe(1);
var snap = snapshots[0];
snap.StatusCode.ShouldNotBe(0x80020000u,
$"Read returned BadInternalError for {full} — the Host couldn't fulfil the request. " +
$"Investigate: the Host service's logs at {System.Environment.GetFolderPath(System.Environment.SpecialFolder.CommonApplicationData)}\\OtOpcUa\\Galaxy\\logs.");
}
/// <summary>
/// Minimal <see cref="IAddressSpaceBuilder"/> implementation that captures every
/// Variable() call into a flat list so tests can inspect what discovery produced
/// without running the full OPC UA node-manager stack.
/// </summary>
private sealed class CapturingAddressSpaceBuilder : IAddressSpaceBuilder
{
public List<(string BrowseName, DriverAttributeInfo AttributeInfo)> Variables { get; } = [];
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
{
Variables.Add((browseName, attributeInfo));
return new NoopHandle(attributeInfo.FullName);
}
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
private sealed class NoopHandle(string fullReference) : IVariableHandle
{
public string FullReference { get; } = fullReference;
public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => new NoopSink();
private sealed class NoopSink : IAlarmConditionSink
{
public void OnTransition(AlarmEventArgs args) { }
}
}
}
}

View File

@@ -22,6 +22,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Proxy.csproj"/>
<ProjectReference Include="..\..\src\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Host.csproj"/>
<ProjectReference Include="..\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport\ZB.MOM.WW.OtOpcUa.Driver.Galaxy.TestSupport.csproj"/>
</ItemGroup>
<ItemGroup>

View File

@@ -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;
/// <summary>
/// Unit coverage for the static helpers <see cref="DriverNodeManager"/> exposes to bridge
/// driver-side history data (<see cref="HistoricalEvent"/> + <see cref="DataValueSnapshot"/>)
/// to the OPC UA on-wire shape (<c>HistoryData</c> / <c>HistoryEvent</c> wrapped in an
/// <see cref="ExtensionObject"/>). Fast, framework-only — no server fixture.
/// </summary>
[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_<name> 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<HistoryData>();
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<HistoryEvent>();
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);
}
}

View File

@@ -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;
/// <summary>
/// End-to-end test that a real OPC UA client's HistoryRead service reaches a fake driver's
/// <see cref="IHistoryProvider"/> via <see cref="DriverNodeManager"/>'s
/// <c>HistoryReadRawModified</c> / <c>HistoryReadProcessed</c> / <c>HistoryReadAtTime</c> /
/// <c>HistoryReadEvents</c> overrides. Boots the full OPC UA stack + a stub
/// <see cref="IHistoryProvider"/> driver, opens a client session, issues each HistoryRead
/// variant, and asserts the client receives the expected per-kind payload.
/// </summary>
[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<OpcUaApplicationHost>.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<ISession> 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);
}
/// <summary>
/// Stub driver that implements <see cref="IHistoryProvider"/> 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.
/// </summary>
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<DateTime>? 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<IReadOnlyList<DataValueSnapshot>> ReadAsync(
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken)
{
var now = DateTime.UtcNow;
IReadOnlyList<DataValueSnapshot> r =
[.. fullReferences.Select(_ => new DataValueSnapshot(0, 0u, now, now))];
return Task.FromResult(r);
}
public Task<DriverHistoryReadResult> ReadRawAsync(
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
CancellationToken cancellationToken)
{
var samples = new List<DataValueSnapshot>();
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<DriverHistoryReadResult> 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<DriverHistoryReadResult> ReadAtTimeAsync(
string fullReference, IReadOnlyList<DateTime> 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<HistoricalEventsResult> ReadEventsAsync(
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
CancellationToken cancellationToken)
{
var events = new List<HistoricalEvent>();
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));
}
}
}