feat(historian): HistoryRead override (Raw/Processed/AtTime) over IHistorianDataSource
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// A no-op <see cref="IHistorianDataSource"/> — the server's default historian backend when no
|
||||
/// real historian has been registered. Every read returns an EMPTY result (no samples / events,
|
||||
/// null continuation point) so the node-manager's HistoryRead override surfaces
|
||||
/// <c>GoodNoData</c> for a historized node rather than faulting, and
|
||||
/// <see cref="GetHealthSnapshot"/> reports a fully-disabled (zeroed, disconnected) snapshot.
|
||||
/// <para>
|
||||
/// A process-wide singleton via <see cref="Instance"/> (private ctor): it carries no state
|
||||
/// and is immutable, so one shared instance is safe to assign as the node-manager's
|
||||
/// <c>HistorianDataSource</c> default until the Host wires a real source post-start (Task 5).
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class NullHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
/// <summary>The shared singleton instance.</summary>
|
||||
public static readonly NullHistorianDataSource Instance = new();
|
||||
|
||||
private static readonly HistoryReadResult EmptyRead = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
private static readonly HistoricalEventsResult EmptyEvents = new(Array.Empty<HistoricalEvent>(), null);
|
||||
|
||||
private NullHistorianDataSource()
|
||||
{
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HistoryReadResult> ReadRawAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HistoryReadResult> ReadProcessedAsync(
|
||||
string fullReference,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
TimeSpan interval,
|
||||
HistoryAggregateType aggregate,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HistoryReadResult> ReadAtTimeAsync(
|
||||
string fullReference,
|
||||
IReadOnlyList<DateTime> timestampsUtc,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyRead);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName,
|
||||
DateTime startUtc,
|
||||
DateTime endUtc,
|
||||
int maxEvents,
|
||||
CancellationToken cancellationToken) => Task.FromResult(EmptyEvents);
|
||||
|
||||
/// <summary>
|
||||
/// Returns a fully-disabled snapshot — no connections open, every counter zero, every nullable
|
||||
/// field null, no cluster nodes. Pure; never blocks.
|
||||
/// </summary>
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||
TotalQueries: 0,
|
||||
TotalSuccesses: 0,
|
||||
TotalFailures: 0,
|
||||
ConsecutiveFailures: 0,
|
||||
LastSuccessTime: null,
|
||||
LastFailureTime: null,
|
||||
LastError: null,
|
||||
ProcessConnectionOpen: false,
|
||||
EventConnectionOpen: false,
|
||||
ActiveProcessNode: null,
|
||||
ActiveEventNode: null,
|
||||
Nodes: Array.Empty<HistorianClusterNodeState>());
|
||||
|
||||
/// <summary>No-op — the null source owns no unmanaged resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
// Stateless singleton; nothing to release.
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,13 @@ using System.Collections.Concurrent;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security;
|
||||
// The SDK's HistoryRead service result (the value the override fills + hands back) and the historian
|
||||
// data source's read DTO are both named HistoryReadResult. Alias each to keep the two unambiguous:
|
||||
// the SDK result stays unqualified as the dominant name in the override; the source DTO is HistorianRead.
|
||||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
@@ -111,6 +117,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
set => _nodeWriteGateway = value ?? NullOpcUaNodeWriteGateway.Instance;
|
||||
}
|
||||
|
||||
private volatile IHistorianDataSource _historianDataSource = NullHistorianDataSource.Instance;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side read backend for the OPC UA HistoryRead service over historized variable nodes.
|
||||
/// When a client issues a HistoryRead (Raw / Processed / AtTime) against a node materialised
|
||||
/// <c>Historizing</c> (a tag with <see cref="TryGetHistorizedTagname"/> registered), the
|
||||
/// HistoryRead override resolves the node's NodeId to its historian tagname and dispatches to
|
||||
/// this source — so a single registered historian (e.g. Wonderware) serves many drivers' nodes,
|
||||
/// independent of any driver's lifecycle.
|
||||
/// <para>
|
||||
/// Set by the Host at <c>StartAsync</c> (Task 5). The <see cref="NullHistorianDataSource"/>
|
||||
/// default (assigning <c>null</c> restores it) means "no historian wired" → every read
|
||||
/// returns empty, so a historized node's HistoryRead surfaces <c>GoodNoData</c> rather than
|
||||
/// faulting. Backed by a <c>volatile</c> field (auto-properties can't be volatile) to make
|
||||
/// the startup-write / SDK-read-thread handoff explicit: the Host assigns it once at boot on
|
||||
/// the start thread and the SDK reads it on HistoryRead request threads. Unlike
|
||||
/// <see cref="NodeWriteGateway"/>, the HistoryRead override does NOT run under the
|
||||
/// node-manager <c>Lock</c>, so the override may block-bridge to this (async) source.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public IHistorianDataSource HistorianDataSource
|
||||
{
|
||||
get => _historianDataSource;
|
||||
set => _historianDataSource = value ?? NullHistorianDataSource.Instance;
|
||||
}
|
||||
|
||||
/// <summary>Look up a materialised Part 9 alarm-condition node by its alarm node id (the
|
||||
/// ScriptedAlarmId), or null if not yet materialised. Exposed for tests + diagnostics.</summary>
|
||||
/// <param name="alarmNodeId">The alarm node identifier (== ScriptedAlarmId).</param>
|
||||
@@ -975,6 +1007,239 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return _folders.TryGetValue(parentNodeId, out var existing) ? existing : _root!;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Phase C — OPC UA HistoryRead over historized variable nodes.
|
||||
//
|
||||
// The base CustomNodeManager2.HistoryRead (public + protected dispatcher) does the heavy lifting:
|
||||
// it validates handles under Lock, builds `nodesToProcess` (a NodeHandle list for nodes WE own
|
||||
// that carry the HistoryRead access bit), validates the timestamp args, handles
|
||||
// `releaseContinuationPoints`, and dispatches by `details` runtime type to the per-details
|
||||
// protected virtuals below. We override the three variable-history virtuals; HistoryReadEvents is
|
||||
// left to the base (Task 4 adds it). Each override receives the pre-filtered handles and fills
|
||||
// results[handle.Index] / errors[handle.Index] — handle.Index is the original index into the
|
||||
// service-level results/errors lists, seeded by the base. The base pre-seeds every handle's error
|
||||
// to BadHistoryOperationUnsupported, so a handle we don't recognise stays "unsupported" by default.
|
||||
//
|
||||
// NOTE: unlike OnWriteValue, the SDK does NOT hold the node-manager Lock while invoking these, so
|
||||
// block-bridging the async data source (GetAwaiter().GetResult()) is safe — it can't freeze the
|
||||
// address space. Each handle is served in isolation under try/catch so one node's failure (timeout,
|
||||
// backend throw) never throws out of the batch.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Serve a HistoryRead-Raw request over the pre-filtered historized variable handles, dispatching
|
||||
/// each to <see cref="IHistorianDataSource.ReadRawAsync"/>. Modified-history reads
|
||||
/// (<c>IsReadModified</c>) are unsupported — we don't serve a modified-value history surface.
|
||||
/// </summary>
|
||||
protected override void HistoryReadRawModified(
|
||||
ServerSystemContext context,
|
||||
ReadRawModifiedDetails details,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
if (details.IsReadModified)
|
||||
{
|
||||
// We never serve modified-value history; mark this node unsupported and move on.
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
continue;
|
||||
}
|
||||
|
||||
ServeNode(handle, results, errors, source => source.ReadRawAsync(
|
||||
ResolveTagname(handle),
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
details.NumValuesPerNode,
|
||||
CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serve a HistoryRead-Processed request, mapping each node's per-node aggregate NodeId (from the
|
||||
/// parallel <c>AggregateType</c> collection — the base guarantees it is the same length as
|
||||
/// <c>nodesToRead</c>) to a <see cref="HistoryAggregateType"/> and dispatching to
|
||||
/// <see cref="IHistorianDataSource.ReadProcessedAsync"/>. An unknown aggregate yields
|
||||
/// <c>BadAggregateNotSupported</c> for that node.
|
||||
/// </summary>
|
||||
protected override void HistoryReadProcessed(
|
||||
ServerSystemContext context,
|
||||
ReadProcessedDetails details,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
// AggregateType is a per-node parallel collection (same length as nodesToRead, enforced by
|
||||
// the base dispatcher). handle.Index is the node's position in that collection.
|
||||
var aggregateNodeId = details.AggregateType[handle.Index];
|
||||
var aggregate = MapAggregate(aggregateNodeId);
|
||||
if (aggregate is null)
|
||||
{
|
||||
errors[handle.Index] = StatusCodes.BadAggregateNotSupported;
|
||||
continue;
|
||||
}
|
||||
|
||||
ServeNode(handle, results, errors, source => source.ReadProcessedAsync(
|
||||
ResolveTagname(handle),
|
||||
details.StartTime,
|
||||
details.EndTime,
|
||||
// OPC UA ProcessingInterval is a Duration in milliseconds.
|
||||
TimeSpan.FromMilliseconds(details.ProcessingInterval),
|
||||
aggregate.Value,
|
||||
CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Serve a HistoryRead-AtTime request, dispatching the requested timestamps to
|
||||
/// <see cref="IHistorianDataSource.ReadAtTimeAsync"/>.
|
||||
/// </summary>
|
||||
protected override void HistoryReadAtTime(
|
||||
ServerSystemContext context,
|
||||
ReadAtTimeDetails details,
|
||||
TimestampsToReturn timestampsToReturn,
|
||||
IList<HistoryReadValueId> nodesToRead,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> cache)
|
||||
{
|
||||
// Snapshot the requested timestamps once — the same list is read for every node.
|
||||
var timestamps = details.ReqTimes?.ToList() ?? new List<DateTime>();
|
||||
foreach (var handle in nodesToProcess)
|
||||
{
|
||||
ServeNode(handle, results, errors, source => source.ReadAtTimeAsync(
|
||||
ResolveTagname(handle),
|
||||
timestamps,
|
||||
CancellationToken.None));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Block-bridge to the historian source for one node handle and project the result onto the
|
||||
/// service-level results/errors slots. Resolves the node's registered historian tagname first
|
||||
/// (a node we don't recognise as historized — which shouldn't reach us, since the base only
|
||||
/// hands us nodes with the HistoryRead access bit — maps to <c>BadHistoryOperationUnsupported</c>).
|
||||
/// The <paramref name="read"/> callback is invoked only AFTER the tagname is confirmed present;
|
||||
/// it is wrapped in try/catch so a backend throw / timeout becomes a Bad status for THIS node
|
||||
/// without throwing out of the batch.
|
||||
/// </summary>
|
||||
/// <param name="handle">The pre-filtered node handle to serve; <c>handle.Index</c> indexes results/errors.</param>
|
||||
/// <param name="results">The service-level results list to fill at <c>handle.Index</c>.</param>
|
||||
/// <param name="errors">The service-level errors list to fill at <c>handle.Index</c>.</param>
|
||||
/// <param name="read">Invokes the resolved data-source read; only called once the tagname is known.</param>
|
||||
private void ServeNode(
|
||||
NodeHandle handle,
|
||||
IList<SdkHistoryReadResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
Func<IHistorianDataSource, Task<HistorianRead>> read)
|
||||
{
|
||||
var idString = handle.NodeId.Identifier?.ToString();
|
||||
if (idString is null || !TryGetHistorizedTagname(idString, out _))
|
||||
{
|
||||
// Not a historized node we own a tagname for — unsupported. (The base pre-seeds this same
|
||||
// status, but set it explicitly so the contract is local + obvious.)
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// HistoryRead is NOT invoked under the node-manager Lock (unlike OnWriteValue), so blocking
|
||||
// on the async source here is safe and won't freeze the address space.
|
||||
var sourceResult = read(HistorianDataSource).GetAwaiter().GetResult();
|
||||
var historyData = ToHistoryData(sourceResult);
|
||||
|
||||
results[handle.Index] = new SdkHistoryReadResult
|
||||
{
|
||||
// No source samples ⇒ GoodNoData (the node is historized, the window just held no data).
|
||||
StatusCode = historyData.DataValues.Count == 0 ? StatusCodes.GoodNoData : StatusCodes.Good,
|
||||
HistoryData = new ExtensionObject(historyData),
|
||||
// We never issue continuation points — every read returns the full window in one shot.
|
||||
ContinuationPoint = null,
|
||||
};
|
||||
errors[handle.Index] = ServiceResult.Good;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// One node's backend failure (throw / timeout / cancellation) must not throw out of the
|
||||
// batch — surface a Bad status for THIS node only. This CustomNodeManager2 carries no
|
||||
// ILogger (see ReportConditionEvent), so log through the SDK's static trace rather than
|
||||
// swallowing silently. Utils.LogError is [Obsolete] in 1.5.378 (favours an ITelemetryContext
|
||||
// this manager doesn't wire) — suppress the deprecation, matching the existing pattern.
|
||||
#pragma warning disable CS0618 // Type or member is obsolete
|
||||
Utils.LogError(ex, "OtOpcUaNodeManager: HistoryRead failed for node {0}", handle.NodeId);
|
||||
#pragma warning restore CS0618
|
||||
errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Resolve a handle's registered historian tagname. The caller (<see cref="ServeNode"/>)
|
||||
/// has already confirmed the node is historized before invoking the read callback, so this is a
|
||||
/// guaranteed hit; the null-coalesce is a defensive fallback to the bare NodeId string.</summary>
|
||||
private string ResolveTagname(NodeHandle handle)
|
||||
{
|
||||
var idString = handle.NodeId.Identifier?.ToString() ?? string.Empty;
|
||||
return TryGetHistorizedTagname(idString, out var tagname) ? tagname! : idString;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Map an OPC UA Part 13 standard-aggregate function NodeId to our
|
||||
/// <see cref="HistoryAggregateType"/>. Returns <c>null</c> for any aggregate we don't serve so
|
||||
/// the caller can surface <c>BadAggregateNotSupported</c>.
|
||||
/// </summary>
|
||||
/// <param name="aggregateNodeId">The per-node aggregate-function NodeId from the request.</param>
|
||||
/// <returns>The mapped aggregate, or <c>null</c> when unsupported.</returns>
|
||||
private static HistoryAggregateType? MapAggregate(NodeId aggregateNodeId)
|
||||
{
|
||||
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>
|
||||
/// Project the historian source's <see cref="HistorianRead"/> (Core.Abstractions DTO) into an
|
||||
/// SDK <see cref="HistoryData"/> — one <see cref="DataValue"/> per <see cref="DataValueSnapshot"/>,
|
||||
/// carrying value / status / source+server timestamps. A null SourceTimestamp maps to
|
||||
/// <c>DateTime.MinValue</c> (the SDK's "unset" sentinel for that field).
|
||||
/// </summary>
|
||||
/// <param name="sourceResult">The data source's read result.</param>
|
||||
/// <returns>The populated SDK <see cref="HistoryData"/>.</returns>
|
||||
private static HistoryData ToHistoryData(HistorianRead sourceResult)
|
||||
{
|
||||
var values = new DataValueCollection(sourceResult.Samples.Count);
|
||||
foreach (var sample in sourceResult.Samples)
|
||||
{
|
||||
values.Add(ToSdkDataValue(sample));
|
||||
}
|
||||
|
||||
return new HistoryData { DataValues = values };
|
||||
}
|
||||
|
||||
/// <summary>Convert one driver-agnostic <see cref="DataValueSnapshot"/> to an SDK
|
||||
/// <see cref="DataValue"/>, mirroring value / status code / source + server timestamps.</summary>
|
||||
/// <param name="snapshot">The source sample.</param>
|
||||
/// <returns>The equivalent SDK data value.</returns>
|
||||
private static DataValue ToSdkDataValue(DataValueSnapshot snapshot) => new()
|
||||
{
|
||||
WrappedValue = new Variant(snapshot.Value),
|
||||
StatusCode = new StatusCode(snapshot.StatusCode),
|
||||
SourceTimestamp = snapshot.SourceTimestampUtc ?? DateTime.MinValue,
|
||||
ServerTimestamp = snapshot.ServerTimestampUtc,
|
||||
};
|
||||
|
||||
/// <inheritdoc />
|
||||
public override void CreateAddressSpace(IDictionary<NodeId, IList<IReference>> externalReferences)
|
||||
{
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests.Historian;
|
||||
|
||||
/// <summary>
|
||||
/// The default no-op historian source. Every read returns an empty result with a null
|
||||
/// continuation point; <see cref="NullHistorianDataSource.GetHealthSnapshot"/> reports a fully
|
||||
/// disabled shape; and <see cref="NullHistorianDataSource.Instance"/> is the shared singleton.
|
||||
/// </summary>
|
||||
public sealed class NullHistorianDataSourceTests
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private static readonly NullHistorianDataSource Source = NullHistorianDataSource.Instance;
|
||||
|
||||
/// <summary><see cref="NullHistorianDataSource.Instance"/> is a single shared instance.</summary>
|
||||
[Fact]
|
||||
public void Instance_is_a_singleton()
|
||||
{
|
||||
NullHistorianDataSource.Instance.ShouldBeSameAs(NullHistorianDataSource.Instance);
|
||||
}
|
||||
|
||||
/// <summary>ReadRawAsync returns no samples and a null continuation point.</summary>
|
||||
[Fact]
|
||||
public async Task ReadRawAsync_returns_empty()
|
||||
{
|
||||
var result = await Source.ReadRawAsync(
|
||||
"WW.Tag", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxValuesPerNode: 100, Ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>ReadProcessedAsync returns no samples and a null continuation point.</summary>
|
||||
[Fact]
|
||||
public async Task ReadProcessedAsync_returns_empty()
|
||||
{
|
||||
var result = await Source.ReadProcessedAsync(
|
||||
"WW.Tag", DateTime.UtcNow.AddHours(-1), DateTime.UtcNow,
|
||||
TimeSpan.FromSeconds(10), HistoryAggregateType.Average, Ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>ReadAtTimeAsync returns no samples and a null continuation point.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAtTimeAsync_returns_empty()
|
||||
{
|
||||
var result = await Source.ReadAtTimeAsync(
|
||||
"WW.Tag", new[] { DateTime.UtcNow }, Ct);
|
||||
|
||||
result.Samples.ShouldBeEmpty();
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>ReadEventsAsync returns no events and a null continuation point.</summary>
|
||||
[Fact]
|
||||
public async Task ReadEventsAsync_returns_empty()
|
||||
{
|
||||
var result = await Source.ReadEventsAsync(
|
||||
sourceName: null, DateTime.UtcNow.AddHours(-1), DateTime.UtcNow, maxEvents: 0, Ct);
|
||||
|
||||
result.Events.ShouldBeEmpty();
|
||||
result.ContinuationPoint.ShouldBeNull();
|
||||
}
|
||||
|
||||
/// <summary>GetHealthSnapshot reports a fully-disabled (zeroed, disconnected, no-node) shape.</summary>
|
||||
[Fact]
|
||||
public void GetHealthSnapshot_is_disabled()
|
||||
{
|
||||
var snapshot = Source.GetHealthSnapshot();
|
||||
|
||||
snapshot.TotalQueries.ShouldBe(0);
|
||||
snapshot.TotalSuccesses.ShouldBe(0);
|
||||
snapshot.TotalFailures.ShouldBe(0);
|
||||
snapshot.ConsecutiveFailures.ShouldBe(0);
|
||||
snapshot.LastSuccessTime.ShouldBeNull();
|
||||
snapshot.LastFailureTime.ShouldBeNull();
|
||||
snapshot.LastError.ShouldBeNull();
|
||||
snapshot.ProcessConnectionOpen.ShouldBeFalse();
|
||||
snapshot.EventConnectionOpen.ShouldBeFalse();
|
||||
snapshot.ActiveProcessNode.ShouldBeNull();
|
||||
snapshot.ActiveEventNode.ShouldBeNull();
|
||||
snapshot.Nodes.ShouldBeEmpty();
|
||||
}
|
||||
|
||||
/// <summary>Dispose is a safe no-op (idempotent).</summary>
|
||||
[Fact]
|
||||
public void Dispose_is_a_noop()
|
||||
{
|
||||
Should.NotThrow(() =>
|
||||
{
|
||||
Source.Dispose();
|
||||
Source.Dispose();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
||||
using SdkHistoryReadResult = Opc.Ua.HistoryReadResult;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// Phase C Task 3 — the node-manager's OPC UA HistoryRead override (Raw / Processed / AtTime) over
|
||||
/// historized variable nodes. Boots a real <see cref="OtOpcUaSdkServer"/> (the same harness
|
||||
/// <see cref="NodeManagerHistorizeTests"/> uses), materialises a historized variable via
|
||||
/// <see cref="OtOpcUaNodeManager.EnsureVariable"/>, wires a recording fake
|
||||
/// <see cref="IHistorianDataSource"/>, then invokes the node manager's PUBLIC
|
||||
/// <c>HistoryRead(OperationContext, …)</c> directly. The base CustomNodeManager2 builds the node
|
||||
/// handles + dispatches to the protected per-details overrides, so this exercises the real dispatch
|
||||
/// path in-process — fast + deterministic, no client socket.
|
||||
/// </summary>
|
||||
public sealed class NodeManagerHistoryReadTests : IDisposable
|
||||
{
|
||||
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
||||
|
||||
private readonly string _pkiRoot = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"otopcua-historyread-{Guid.NewGuid():N}");
|
||||
|
||||
/// <summary>Raw read: the fake receives the resolved tagname + StartTime/EndTime/NumValuesPerNode,
|
||||
/// and the returned samples decode to a HistoryData whose DataValues mirror value/status/source+server
|
||||
/// timestamps. StatusCode is Good when samples are present.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_dispatches_to_source_and_maps_samples()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource();
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/temp", parentFolderNodeId: null, displayName: "Temp", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Temp");
|
||||
var nodeId = nm.TryGetVariable("eq-1/temp")!.NodeId;
|
||||
|
||||
var src = DateTime.UtcNow.AddSeconds(-5);
|
||||
var srv = DateTime.UtcNow;
|
||||
fake.RawResult = new HistorianRead(
|
||||
new[] { new DataValueSnapshot(42.5f, StatusCodes.Good, src, srv) }, null);
|
||||
|
||||
var start = DateTime.UtcNow.AddHours(-1);
|
||||
var end = DateTime.UtcNow;
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = start,
|
||||
EndTime = end,
|
||||
NumValuesPerNode = 100,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
// The source saw the resolved tagname + the request window + cap.
|
||||
fake.LastCall.ShouldBe("Raw");
|
||||
fake.LastTagname.ShouldBe("WW.Temp");
|
||||
fake.LastStart.ShouldBe(start);
|
||||
fake.LastEnd.ShouldBe(end);
|
||||
fake.LastMaxValues.ShouldBe(100u);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
data.DataValues.Count.ShouldBe(1);
|
||||
var dv = data.DataValues[0];
|
||||
dv.Value.ShouldBe(42.5f);
|
||||
dv.StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
dv.SourceTimestamp.ShouldBe(src);
|
||||
dv.ServerTimestamp.ShouldBe(srv);
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>The resolved tagname that reaches the source is the OVERRIDE tagname (distinct from the
|
||||
/// NodeId / FullName) — the override resolves the NodeId→tagname map, not the bare NodeId.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_resolves_override_tagname_not_node_id()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
// Node id "eq-9/flow" but a DISTINCT historian tagname "Plant.Flow.PV".
|
||||
nm.EnsureVariable("eq-9/flow", parentFolderNodeId: null, displayName: "Flow", dataType: "Double",
|
||||
writable: false, historianTagname: "Plant.Flow.PV");
|
||||
var nodeId = nm.TryGetVariable("eq-9/flow")!.NodeId;
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
// ReadRawModifiedDetails defaults IsReadModified=true; a raw (non-modified) read clears it.
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
fake.LastTagname.ShouldBe("Plant.Flow.PV");
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Empty samples ⇒ the node's StatusCode is GoodNoData (the node is historized, the window
|
||||
/// just held no data).</summary>
|
||||
[Fact]
|
||||
public async Task Raw_empty_samples_yields_GoodNoData()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/empty", parentFolderNodeId: null, displayName: "Empty", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Empty");
|
||||
var nodeId = nm.TryGetVariable("eq-1/empty")!.NodeId;
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good);
|
||||
results[0].StatusCode.Code.ShouldBe(StatusCodes.GoodNoData);
|
||||
var data = (HistoryData)ExtensionObject.ToEncodeable(results[0].HistoryData);
|
||||
data.DataValues.ShouldBeEmpty();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A non-historized node (plain variable, no HistoryRead bit) reaching HistoryRead yields
|
||||
/// BadHistoryOperationUnsupported — the source is never invoked.</summary>
|
||||
[Fact]
|
||||
public async Task Non_historized_node_yields_BadHistoryOperationUnsupported()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
// Plain (non-historized) variable — no HistoryRead access bit.
|
||||
nm.EnsureVariable("eq-1/plain", parentFolderNodeId: null, displayName: "Plain", dataType: "Int32",
|
||||
writable: false, historianTagname: null);
|
||||
var nodeId = nm.TryGetVariable("eq-1/plain")!.NodeId;
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
};
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
fake.LastCall.ShouldBeNull(); // source never reached
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Raw with IsReadModified=true ⇒ BadHistoryOperationUnsupported (we don't serve modified
|
||||
/// history); the source is never invoked.</summary>
|
||||
[Fact]
|
||||
public async Task Raw_read_modified_yields_BadHistoryOperationUnsupported()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { RawResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/mod", parentFolderNodeId: null, displayName: "Mod", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Mod");
|
||||
var nodeId = nm.TryGetVariable("eq-1/mod")!.NodeId;
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = true,
|
||||
};
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
fake.LastCall.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Processed read with a known aggregate (Average) reaches the source as
|
||||
/// HistoryAggregateType.Average + the ProcessingInterval as a TimeSpan.</summary>
|
||||
[Fact]
|
||||
public async Task Processed_known_aggregate_dispatches_with_interval()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/avg", parentFolderNodeId: null, displayName: "Avg", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Avg");
|
||||
var nodeId = nm.TryGetVariable("eq-1/avg")!.NodeId;
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
ProcessingInterval = 10_000.0, // ms
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_Average },
|
||||
};
|
||||
|
||||
InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
fake.LastCall.ShouldBe("Processed");
|
||||
fake.LastTagname.ShouldBe("WW.Avg");
|
||||
fake.LastAggregate.ShouldBe(HistoryAggregateType.Average);
|
||||
fake.LastInterval.ShouldBe(TimeSpan.FromMilliseconds(10_000.0));
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Processed read with an UNKNOWN aggregate NodeId ⇒ BadAggregateNotSupported; the source
|
||||
/// is never invoked.</summary>
|
||||
[Fact]
|
||||
public async Task Processed_unknown_aggregate_yields_BadAggregateNotSupported()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { ProcessedResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/sd", parentFolderNodeId: null, displayName: "Sd", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Sd");
|
||||
var nodeId = nm.TryGetVariable("eq-1/sd")!.NodeId;
|
||||
|
||||
var details = new ReadProcessedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
ProcessingInterval = 10_000.0,
|
||||
// StandardDeviationSample is a real OPC UA aggregate we do not serve.
|
||||
AggregateType = new NodeIdCollection { ObjectIds.AggregateFunction_StandardDeviationSample },
|
||||
};
|
||||
|
||||
var (_, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadAggregateNotSupported);
|
||||
fake.LastCall.ShouldBeNull();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>AtTime read: the requested timestamps reach ReadAtTimeAsync in order.</summary>
|
||||
[Fact]
|
||||
public async Task AtTime_dispatches_requested_timestamps()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { AtTimeResult = Empty() };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/at", parentFolderNodeId: null, displayName: "At", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.At");
|
||||
var nodeId = nm.TryGetVariable("eq-1/at")!.NodeId;
|
||||
|
||||
var t1 = DateTime.UtcNow.AddMinutes(-2);
|
||||
var t2 = DateTime.UtcNow.AddMinutes(-1);
|
||||
var details = new ReadAtTimeDetails
|
||||
{
|
||||
ReqTimes = new DateTimeCollection { t1, t2 },
|
||||
};
|
||||
|
||||
InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
fake.LastCall.ShouldBe("AtTime");
|
||||
fake.LastTagname.ShouldBe("WW.At");
|
||||
fake.LastTimestamps.ShouldBe(new[] { t1, t2 });
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>A backend that throws ⇒ that node's error is Bad (not GoodNoData) and no exception
|
||||
/// escapes the HistoryRead call.</summary>
|
||||
[Fact]
|
||||
public async Task Backend_throw_yields_bad_status_and_does_not_escape()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
var nm = server.NodeManager!;
|
||||
var fake = new RecordingHistorianDataSource { ThrowOnRead = true };
|
||||
nm.HistorianDataSource = fake;
|
||||
|
||||
nm.EnsureVariable("eq-1/boom", parentFolderNodeId: null, displayName: "Boom", dataType: "Float",
|
||||
writable: false, historianTagname: "WW.Boom");
|
||||
var nodeId = nm.TryGetVariable("eq-1/boom")!.NodeId;
|
||||
|
||||
var details = new ReadRawModifiedDetails
|
||||
{
|
||||
StartTime = DateTime.UtcNow.AddHours(-1),
|
||||
EndTime = DateTime.UtcNow,
|
||||
NumValuesPerNode = 10,
|
||||
IsReadModified = false,
|
||||
};
|
||||
|
||||
// The call must not throw even though the backend does.
|
||||
var (results, errors) = InvokeHistoryRead(server, nm, details, nodeId);
|
||||
|
||||
StatusCode.IsBad(errors[0].StatusCode).ShouldBeTrue();
|
||||
errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported);
|
||||
// The result slot was never filled with a GoodNoData success.
|
||||
(results[0].StatusCode.Code == StatusCodes.GoodNoData).ShouldBeFalse();
|
||||
|
||||
await host.DisposeAsync();
|
||||
}
|
||||
|
||||
/// <summary>Invoke the node manager's public HistoryRead with a single node, returning the filled
|
||||
/// results + errors. Uses a session-less <see cref="OperationContext"/> (the
|
||||
/// (RequestHeader, SecureChannelContext, RequestType, IUserIdentity) ctor) — HistoryRead's
|
||||
/// handle-building only needs the NodeId + namespace, not a session.</summary>
|
||||
private static (IList<SdkHistoryReadResult> Results, IList<ServiceResult> Errors) InvokeHistoryRead(
|
||||
OtOpcUaSdkServer server, OtOpcUaNodeManager nm, HistoryReadDetails details, NodeId nodeId)
|
||||
{
|
||||
var context = new OperationContext(
|
||||
new RequestHeader(), secureChannelContext: null, RequestType.HistoryRead, identity: null);
|
||||
|
||||
var nodesToRead = new List<HistoryReadValueId>
|
||||
{
|
||||
new() { NodeId = nodeId },
|
||||
};
|
||||
var results = new List<SdkHistoryReadResult> { null! };
|
||||
var errors = new List<ServiceResult> { null! };
|
||||
|
||||
nm.HistoryRead(
|
||||
context,
|
||||
details,
|
||||
TimestampsToReturn.Both,
|
||||
releaseContinuationPoints: false,
|
||||
nodesToRead,
|
||||
results,
|
||||
errors);
|
||||
|
||||
return (results, errors);
|
||||
}
|
||||
|
||||
private static HistorianRead Empty() => new(Array.Empty<DataValueSnapshot>(), null);
|
||||
|
||||
/// <summary>A recording fake historian source — captures the last call's kind + arguments and returns
|
||||
/// a configured result (or throws when <see cref="ThrowOnRead"/> is set).</summary>
|
||||
private sealed class RecordingHistorianDataSource : IHistorianDataSource
|
||||
{
|
||||
public bool ThrowOnRead { get; init; }
|
||||
public HistorianRead RawResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
public HistorianRead ProcessedResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
public HistorianRead AtTimeResult { get; set; } = new(Array.Empty<DataValueSnapshot>(), null);
|
||||
|
||||
public string? LastCall { get; private set; }
|
||||
public string? LastTagname { get; private set; }
|
||||
public DateTime LastStart { get; private set; }
|
||||
public DateTime LastEnd { get; private set; }
|
||||
public uint LastMaxValues { get; private set; }
|
||||
public TimeSpan LastInterval { get; private set; }
|
||||
public HistoryAggregateType LastAggregate { get; private set; }
|
||||
public IReadOnlyList<DateTime>? LastTimestamps { get; private set; }
|
||||
|
||||
public Task<HistorianRead> ReadRawAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, uint maxValuesPerNode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
|
||||
LastCall = "Raw";
|
||||
LastTagname = fullReference;
|
||||
LastStart = startUtc;
|
||||
LastEnd = endUtc;
|
||||
LastMaxValues = maxValuesPerNode;
|
||||
return Task.FromResult(RawResult);
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadProcessedAsync(
|
||||
string fullReference, DateTime startUtc, DateTime endUtc, TimeSpan interval,
|
||||
HistoryAggregateType aggregate, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
|
||||
LastCall = "Processed";
|
||||
LastTagname = fullReference;
|
||||
LastStart = startUtc;
|
||||
LastEnd = endUtc;
|
||||
LastInterval = interval;
|
||||
LastAggregate = aggregate;
|
||||
return Task.FromResult(ProcessedResult);
|
||||
}
|
||||
|
||||
public Task<HistorianRead> ReadAtTimeAsync(
|
||||
string fullReference, IReadOnlyList<DateTime> timestampsUtc, CancellationToken cancellationToken)
|
||||
{
|
||||
if (ThrowOnRead) throw new InvalidOperationException("backend boom");
|
||||
LastCall = "AtTime";
|
||||
LastTagname = fullReference;
|
||||
LastTimestamps = timestampsUtc;
|
||||
return Task.FromResult(AtTimeResult);
|
||||
}
|
||||
|
||||
public Task<HistoricalEventsResult> ReadEventsAsync(
|
||||
string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents,
|
||||
CancellationToken cancellationToken) =>
|
||||
Task.FromResult(new HistoricalEventsResult(Array.Empty<HistoricalEvent>(), null));
|
||||
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync()
|
||||
{
|
||||
var host = new OpcUaApplicationHost(
|
||||
new OpcUaApplicationHostOptions
|
||||
{
|
||||
ApplicationName = "OtOpcUa.HistoryReadTest",
|
||||
ApplicationUri = $"urn:OtOpcUa.HistoryReadTest:{Guid.NewGuid():N}",
|
||||
OpcUaPort = AllocateFreePort(),
|
||||
PublicHostname = "localhost",
|
||||
PkiStoreRoot = _pkiRoot,
|
||||
},
|
||||
NullLogger<OpcUaApplicationHost>.Instance);
|
||||
|
||||
var server = new OtOpcUaSdkServer();
|
||||
await host.StartAsync(server, Ct);
|
||||
return (host, server);
|
||||
}
|
||||
|
||||
private static int AllocateFreePort()
|
||||
{
|
||||
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
|
||||
listener.Start();
|
||||
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
|
||||
listener.Stop();
|
||||
return port;
|
||||
}
|
||||
|
||||
/// <summary>Cleans up the PKI root directory.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pkiRoot))
|
||||
{
|
||||
try { Directory.Delete(_pkiRoot, recursive: true); }
|
||||
catch { /* best-effort cleanup */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user