465 lines
19 KiB
C#
465 lines
19 KiB
C#
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 */ }
|
|
}
|
|
}
|
|
}
|