53978ca638
#694 item 3 — historiangw#27 is fixed and now live-verified through OtOpcUa: - ServerHistorian:Gateway:EnableNativeTimeWeighted defaults to true (false is the kill switch for a gateway older than 9f711c1). TimeAverage / Total are served and advertised again (12 aggregates). - The gateway sends the interval as max(1, round(ms)) and AVEVA clips its last interval at the request end (live). NativeAggregatePlan therefore sends a multi-interval grid only for whole-ms intervals; a fractional interval is read one interval per request (at most 1,000, else BadTooManyOperations), and every one-interval request goes out rounded UP to whole ms so AVEVA answers one clipped row. Live, a 3.0004 s clipped interval sent at its own width lost its last 0.4 ms (Total 123 vs 123.0172). #746 — HistorianGateway Contracts/Client 1.0.0 surface: - Alarm history: GatewayAlarmHistorianWriter sends SendEvents batches of <=200. A failed per-event result is re-sent alone via SendEvent (classified as before); a refused batch (InvalidArgument etc.) goes event by event so a poison event dead-letters alone; a systemic fault retries the rest without sending more; Unimplemented (older gateway) falls back to SendEvent for good. IHistorianGatewayClient.SendEventsAsync defaults to Unimplemented. - Events reads: a capped page sends cap + 1 as ReadEventsRequest.max_events, so the gateway completes the stream instead of the client cancelling it. An ambiguous capped answer (foreign rows filtered out of the server's cut) or a refused cap is re-read uncapped with the existing EventDrainBudget drain. Tests: GatewayServerCappedEventsTests, GatewayAlarmWriterBatchTests, GatewayNativeWholeMillisecondGridTests (gateway-faithful rounding fake), gate tests flipped to the new default; every new test mutation-checked (each mutation of the production change fails at least one). Live harness AvevaGatewayHistorianFollowupsLiveGateTests (read-only) for #694/#700/#746. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
751 lines
48 KiB
C#
751 lines
48 KiB
C#
using System.Globalization;
|
|
using System.Text.Json;
|
|
using Microsoft.Extensions.Configuration;
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Host.Configuration;
|
|
using ZB.MOM.WW.OtOpcUa.Runtime.Historian;
|
|
using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Gitea #801 (SDK 2 live gate, "real AVEVA history"): the booted OtOpcUa server, with the <b>production</b>
|
|
/// Gateway backend (<see cref="HistorianBackendRegistry"/> → <c>GatewayHistorianDataSource</c>) pointed at a REAL
|
|
/// HistorianGateway in front of the real AVEVA Historian, serves HistoryRead over a real SDK client session.
|
|
/// Every wire answer is compared with the same backend's direct <see cref="IHistorianDataSource"/> answer
|
|
/// (value, status, source timestamp), and the whole run is dumped as JSON so the same queries can be diffed
|
|
/// against another SDK build and against direct SQL on the historian's <c>Runtime</c> DB.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// <para><b>Read-only by construction.</b> The Gateway backend has no <see cref="IHistorianDataUpdater"/> (its
|
|
/// provider returns none), no provisioner, recorder or alarm writer is built here, and the one HistoryUpdate this
|
|
/// test sends targets a node bound to the designated write-sandbox tag (<c>HISTGW_WRITE_SANDBOX_TAG</c>) and must be
|
|
/// refused by the server before any backend is reached. Without that variable the HistoryUpdate case is skipped.</para>
|
|
/// <para><b>Env (skips when the endpoint or key is unset):</b> <c>HISTGW_GATEWAY_ENDPOINT</c> (e.g.
|
|
/// <c>http://wonder-app-vd03.zmr.zimmer.com:5221</c>), <c>HISTGW_GATEWAY_APIKEY</c> (a key with <c>historian:read</c>;
|
|
/// never logged), optional <c>HISTGW_GATEWAY_ALLOW_UNTRUSTED</c>, <c>HISTGW_WRITE_SANDBOX_TAG</c>,
|
|
/// <c>OTOPCUA_AVEVA_GATE_OUT</c> (output directory for <c>aveva-gate-*.json</c>), <c>OTOPCUA_AVEVA_GATE_LABEL</c>
|
|
/// (a run label recorded in the output, e.g. the SDK version), <c>OTOPCUA_AVEVA_GATE_TAGS</c> (comma list of
|
|
/// historized analog tags), <c>OTOPCUA_AVEVA_GATE_WINDOWS</c> (<c>start/end;start/end</c>, ISO-8601 UTC),
|
|
/// <c>OTOPCUA_AVEVA_GATE_EVENT_SOURCES</c> (comma list), <c>OTOPCUA_AVEVA_GATE_EVENT_WINDOW</c> and
|
|
/// <c>OTOPCUA_AVEVA_GATE_GALAXY_REFS</c> (#886: comma list of Galaxy alarm references whose native conditions are wired to
|
|
/// one equipment folder that is not named after any of their objects). The defaults are the DEV
|
|
/// (wonder-app-vd03 / wonder-sql-vd03) tags and fixed past windows used for the 2026-11-12 run.</para>
|
|
/// <para>The local deterministic fixtures are NOT this test; only a real gateway in front of a real AVEVA
|
|
/// Historian makes its output AVEVA evidence.</para>
|
|
/// </remarks>
|
|
public sealed class AvevaGatewayHistoryLiveGateTests
|
|
{
|
|
private static readonly string[] DefaultTags = ["ZIPSEN.WTC1", "ZIPSEN.OvenPressureTransducer", "SysPerfCPUTotal"];
|
|
|
|
// W1 spans the 2026-11-10 11:32:36Z platform stop on DEV (a stored NULL / Bad row, then a gap and a
|
|
// recovery row); W2 is a quiet stretch. Both are in the past, so every run (and every SDK build) sees the same data.
|
|
private const string DefaultWindows = "2026-11-10T11:30:00Z/2026-11-10T11:41:00Z;2026-11-11T14:00:00Z/2026-11-11T14:10:00Z";
|
|
// AVEVA A&E source names as the gateway reports them. The first must have events in the window (asserted): the
|
|
// platform object, whose System.Stop / System.Start bracket the W1 outage. Since #882 its folder also serves the
|
|
// object's attribute alarms, which the gateway reports with SourceName "DevWinPlatform.CPULoadAvg" (Source_ProcessVariable;
|
|
// Source_Object is DevWinPlatform) — asserted against the unfiltered read. The second is that attribute name as a folder
|
|
// of its own: no attribute-level notifier exists, and the gateway (Source_Object = the object) has nothing under it, so
|
|
// it stays GoodNoData. Recorded (wire must equal the backend), not required to be non-empty.
|
|
private const string DefaultEventSources = "DevWinPlatform,DevWinPlatform.CPULoadAvg";
|
|
private const string DefaultEventWindow = "2026-11-05T15:00:00Z/2026-11-12T00:00:00Z";
|
|
|
|
// #886: a REAL equipment folder — its id is not a Galaxy object name — whose native conditions dial these Galaxy alarm
|
|
// references (attributeRef). Two objects with A&E history on DEV in the event window: the platform's CPU-load alarm
|
|
// and a ZIPSEN Left_001 machine alarm. The folder must serve both objects' events and attribute alarms.
|
|
private const string DefaultGalaxyAlarmRefs = "DevWinPlatform.CPULoadHiAlarm,Left_001.FRR_Warning";
|
|
private const string GalaxyEquipment = "EQ-886-ZipsenLeft";
|
|
private const string MissingTag = "OtOpcUa.Gate801.DoesNotExist";
|
|
private const double IntervalMs = 60_000;
|
|
|
|
// The aggregates the Gateway backend serves: the ten per-sample ones (#681) plus AVEVA's native TimeAverage / Total (on by
|
|
// default since lmxopcua#694), and a set it must refuse (the other time-weighted ones / never).
|
|
private static readonly (string Name, NodeId Id, HistoryAggregateType? Domain)[] Served =
|
|
[
|
|
("Average", ObjectIds.AggregateFunction_Average, HistoryAggregateType.Average),
|
|
("Count", ObjectIds.AggregateFunction_Count, HistoryAggregateType.Count),
|
|
("Minimum", ObjectIds.AggregateFunction_Minimum, HistoryAggregateType.Minimum),
|
|
("Maximum", ObjectIds.AggregateFunction_Maximum, HistoryAggregateType.Maximum),
|
|
("Range", ObjectIds.AggregateFunction_Range, HistoryAggregateType.Range),
|
|
("Start", ObjectIds.AggregateFunction_Start, HistoryAggregateType.Start),
|
|
("End", ObjectIds.AggregateFunction_End, HistoryAggregateType.End),
|
|
("Delta", ObjectIds.AggregateFunction_Delta, HistoryAggregateType.Delta),
|
|
("MinimumActualTime", ObjectIds.AggregateFunction_MinimumActualTime, HistoryAggregateType.MinimumActualTime),
|
|
("MaximumActualTime", ObjectIds.AggregateFunction_MaximumActualTime, HistoryAggregateType.MaximumActualTime),
|
|
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage, HistoryAggregateType.TimeAverage),
|
|
("Total", ObjectIds.AggregateFunction_Total, HistoryAggregateType.Total),
|
|
];
|
|
|
|
private static readonly (string Name, NodeId Id)[] Refused =
|
|
[
|
|
("TimeAverage2", ObjectIds.AggregateFunction_TimeAverage2),
|
|
("Interpolative", ObjectIds.AggregateFunction_Interpolative),
|
|
("PercentGood", ObjectIds.AggregateFunction_PercentGood),
|
|
("DurationGood", ObjectIds.AggregateFunction_DurationGood),
|
|
("StandardDeviationSample", ObjectIds.AggregateFunction_StandardDeviationSample),
|
|
];
|
|
|
|
[Fact]
|
|
public async Task Real_AVEVA_history_served_over_the_wire_matches_the_gateway_backend()
|
|
{
|
|
var endpoint = Env("HISTGW_GATEWAY_ENDPOINT");
|
|
var apiKey = Env("HISTGW_GATEWAY_APIKEY");
|
|
if (endpoint is null || apiKey is null)
|
|
Assert.Skip("Set HISTGW_GATEWAY_ENDPOINT and HISTGW_GATEWAY_APIKEY to run the #801 real-AVEVA history gate.");
|
|
|
|
var ct = TestContext.Current.CancellationToken;
|
|
var label = Env("OTOPCUA_AVEVA_GATE_LABEL") ?? "unlabelled";
|
|
var tags = (Env("OTOPCUA_AVEVA_GATE_TAGS") ?? string.Join(',', DefaultTags))
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var windows = ParseWindows(Env("OTOPCUA_AVEVA_GATE_WINDOWS") ?? DefaultWindows);
|
|
var eventSources = (Env("OTOPCUA_AVEVA_GATE_EVENT_SOURCES") ?? DefaultEventSources)
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
var eventWindow = ParseWindows(Env("OTOPCUA_AVEVA_GATE_EVENT_WINDOW") ?? DefaultEventWindow)[0];
|
|
var sandboxTag = Env("HISTGW_WRITE_SANDBOX_TAG");
|
|
var galaxyRefs = (Env("OTOPCUA_AVEVA_GATE_GALAXY_REFS") ?? DefaultGalaxyAlarmRefs)
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
|
|
// The production backend path: the same registry + provider + factory the Host uses for Backend=Gateway.
|
|
var options = new ServerHistorianOptions
|
|
{
|
|
Backend = "Gateway",
|
|
Enabled = true,
|
|
Endpoint = endpoint!,
|
|
ApiKey = apiKey!,
|
|
UseTls = endpoint!.StartsWith("https://", StringComparison.OrdinalIgnoreCase),
|
|
AllowUntrustedServerCertificate = string.Equals(Env("HISTGW_GATEWAY_ALLOW_UNTRUSTED"), "true", StringComparison.OrdinalIgnoreCase),
|
|
};
|
|
var provider = HistorianBackendRegistry.Resolve(options, new ConfigurationBuilder().Build());
|
|
provider.CreateUpdater(new ServiceCollection().BuildServiceProvider())
|
|
.ShouldBeNull("the Gateway backend must not offer HistoryUpdate — this gate never writes through it");
|
|
using var source = provider.CreateDataSource(new ServiceCollection().BuildServiceProvider());
|
|
|
|
var report = new Report(label, endpoint!);
|
|
var failures = new List<string>();
|
|
|
|
await using var rig = await StartAsync("AvevaGate801", encrypted: false, ct);
|
|
rig.Nm.HistorianDataSource = source;
|
|
// The Host copies these from ServerHistorianOptions (OtOpcUaServerHostedService); defaults here.
|
|
rig.Nm.MaxTieClusterOverfetch = options.MaxTieClusterOverfetch;
|
|
rig.Nm.HonorReturnBounds = options.HonorReturnBounds;
|
|
rig.Nm.EventReadHorizon = options.EventReadHorizon;
|
|
rig.Nm.HistoryReadBatchParallelism = options.HistoryReadBatchParallelism;
|
|
rig.Nm.MaxConcurrentHistoryReadBatches = options.MaxConcurrentHistoryReadBatches;
|
|
rig.Nm.HistoryReadDeadline = options.HistoryReadDeadline;
|
|
rig.Enforce(false, [Key()]);
|
|
|
|
var nodes = new Dictionary<string, string>(StringComparer.Ordinal);
|
|
for (var i = 0; i < tags.Length; i++) nodes[tags[i]] = rig.EnsureRawVariable($"Hist{i}", "Double", writable: false, tags[i]);
|
|
var missingNode = rig.EnsureRawVariable("HistMissing", "Double", writable: false, MissingTag);
|
|
var sysTimeNode = rig.EnsureRawVariable("HistSysTimeSec", "Double", writable: false, "SysTimeSec");
|
|
var sandboxNode = sandboxTag is null ? null : rig.EnsureRawVariable("HistSandbox", "Double", writable: false, sandboxTag);
|
|
// An event-history source: a folder whose id is the historian source name, promoted by a native condition.
|
|
foreach (var eventSource in eventSources)
|
|
{
|
|
rig.Sink.EnsureFolder(eventSource, parentNodeId: null, displayName: eventSource, realm: AddressSpaceRealm.Raw);
|
|
rig.Sink.MaterialiseAlarmCondition($"{eventSource}/gate801", eventSource, "gate801", "OffNormalAlarm", 500,
|
|
AddressSpaceRealm.Raw, isNative: true);
|
|
}
|
|
|
|
// #886: the applier's shape for a Galaxy deployment — each native condition under its raw device folder, bound to
|
|
// its Galaxy alarm reference and wired to the one equipment folder that references both.
|
|
rig.Sink.EnsureFolder(GalaxyEquipment, parentNodeId: null, displayName: "ZIPSEN Left line", realm: AddressSpaceRealm.Uns);
|
|
var galaxyConditions = new Dictionary<string, (string RawPath, string Name)>(StringComparer.OrdinalIgnoreCase);
|
|
foreach (var reference in galaxyRefs)
|
|
{
|
|
var objectName = GalaxyAttributeRef.ObjectNameOf(reference)!;
|
|
var name = reference[(reference.IndexOf('.') + 1)..].Replace(".", "_", StringComparison.Ordinal);
|
|
var deviceFolder = $"Plant/Galaxy/{objectName}";
|
|
var rawPath = $"{deviceFolder}/{name}";
|
|
rig.Sink.EnsureFolder(deviceFolder, parentNodeId: null, displayName: objectName, realm: AddressSpaceRealm.Raw);
|
|
rig.Sink.MaterialiseAlarmCondition(rawPath, deviceFolder, name, "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true);
|
|
rig.Sink.BindNativeAlarmHistory(rawPath, AddressSpaceRealm.Raw, reference);
|
|
rig.Sink.WireAlarmNotifiers(rawPath, AddressSpaceRealm.Raw, [GalaxyEquipment], AddressSpaceRealm.Uns);
|
|
galaxyConditions[reference] = (rawPath, name);
|
|
}
|
|
|
|
using var session = await rig.ConnectAsync(anonymous: true, ct);
|
|
var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri);
|
|
NodeId Node(string id) => new(id, ns);
|
|
|
|
// ---- Metadata: node attributes and the HistoryServerCapabilities the server advertises.
|
|
foreach (var (tag, id) in nodes.Take(1))
|
|
{
|
|
var attrs = await ReadAttributesAsync(session, Node(id), ct);
|
|
report.Add(new Case("metadata.node", tag, null, "wire", "Good", null, attrs));
|
|
Check(failures, attrs["Historizing"] == "Boolean:True", "Historizing must be true on a historized node");
|
|
var access = byte.Parse(attrs["AccessLevel"]!.Replace("Byte:", "", StringComparison.Ordinal), CultureInfo.InvariantCulture);
|
|
Check(failures, (access & AccessLevels.HistoryRead) != 0, "AccessLevel must carry HistoryRead");
|
|
Check(failures, (access & AccessLevels.HistoryWrite) == 0, "AccessLevel must not carry HistoryWrite on the Gateway backend");
|
|
}
|
|
var caps = await ReadHistoryCapabilitiesAsync(session, ct);
|
|
report.Add(new Case("metadata.historyServerCapabilities", null, null, "wire", "Good", null, caps));
|
|
Check(failures, caps["AggregateFunctions"] == string.Join(",", Served.Select(a => a.Name).OrderBy(n => n, StringComparer.Ordinal)),
|
|
$"advertised history aggregates must be exactly the Gateway's twelve, got {caps["AggregateFunctions"]}");
|
|
// lmxopcua#881: the Boolean capabilities follow the Gateway backend — data + event reads, no HistoryUpdate path, no
|
|
// stored server timestamps, never event / annotation updates (this gate recorded all-false before the fix).
|
|
foreach (var flag in new[] { "AccessHistoryDataCapability", "AccessHistoryEventsCapability" })
|
|
Check(failures, caps.GetValueOrDefault(flag) == "Boolean:True", $"{flag} must be true on the Gateway backend, got {caps.GetValueOrDefault(flag)}");
|
|
foreach (var flag in new[]
|
|
{
|
|
"ServerTimestampSupported", "InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability",
|
|
"DeleteRawCapability", "DeleteAtTimeCapability", "InsertEventCapability", "ReplaceEventCapability",
|
|
"UpdateEventCapability", "DeleteEventCapability", "InsertAnnotationCapability",
|
|
})
|
|
Check(failures, caps.GetValueOrDefault(flag) == "Boolean:False", $"{flag} must be false on the Gateway backend, got {caps.GetValueOrDefault(flag)}");
|
|
|
|
foreach (var (tag, id) in nodes)
|
|
{
|
|
foreach (var (start, end) in windows)
|
|
{
|
|
var w = $"{start:o}/{end:o}";
|
|
|
|
// ---- Raw, all values, no bounds.
|
|
var wire = await ReadRawAsync(session, Node(id), start, end, 0, returnBounds: false, ct);
|
|
var direct = await source.ReadRawAsync(tag, start, end, 0, ct);
|
|
report.Add(new Case("raw.all", tag, w, "wire", wire.Status, null, null, wire.Rows));
|
|
report.Add(new Case("raw.all", tag, w, "direct", "Good", null, null, Rows(direct.Samples)));
|
|
CheckSame(failures, $"raw.all {tag} {w}", wire.Rows, Rows(direct.Samples));
|
|
Check(failures, wire.Rows.Count > 0, $"raw.all {tag} {w}: expected stored samples");
|
|
|
|
// ---- Raw, paged through continuation points (page 37).
|
|
var paged = await ReadRawPagedAsync(session, Node(id), start, end, 37, ct);
|
|
report.Add(new Case("raw.paged37", tag, w, "wire", paged.Status, $"pages={paged.Pages}", null, paged.Rows));
|
|
CheckSame(failures, $"raw.paged37 {tag} {w}", paged.Rows, wire.Rows);
|
|
|
|
// ---- Raw, reverse (StartTime = end > EndTime = start), first page of 25, newest first. Part 11: the
|
|
// StartTime is included and the EndTime excluded, so the reverse window is (start, end] — a sample stored
|
|
// exactly at `end` is in it (and not in the forward read). Reference: the backend's (start, end] forward.
|
|
var reverse = await ReadRawAsync(session, Node(id), end, start, 25, returnBounds: false, ct);
|
|
var reverseRef = await source.ReadRawAsync(tag, start.AddTicks(1), end.AddTicks(1), 0, ct);
|
|
report.Add(new Case("raw.reverse25", tag, w, "wire", reverse.Status, null, null, reverse.Rows));
|
|
CheckSame(failures, $"raw.reverse25 {tag} {w}", reverse.Rows,
|
|
Rows(reverseRef.Samples).AsEnumerable().Reverse().Take(25).ToList());
|
|
|
|
// ---- Raw with ReturnBounds (Part 11 bounding values; served by the server, #691).
|
|
var bounds = await ReadRawAsync(session, Node(id), start, end, 0, returnBounds: true, ct);
|
|
report.Add(new Case("raw.bounds", tag, w, "wire", bounds.Status, null, null, bounds.Rows));
|
|
Check(failures, bounds.Rows.Count >= wire.Rows.Count, $"raw.bounds {tag} {w}: fewer rows than raw");
|
|
|
|
// ---- Modified (unsupported on Gateway).
|
|
var modified = await ReadModifiedAsync(session, Node(id), start, end, ct);
|
|
report.Add(new Case("modified", tag, w, "wire", modified, null, null));
|
|
Check(failures, modified == "BadHistoryOperationUnsupported", $"modified {tag} {w}: got {modified}");
|
|
|
|
// ---- Processed: the ten served aggregates, wire vs the backend's own answer.
|
|
foreach (var (name, aggId, domain) in Served)
|
|
{
|
|
var p = await ReadProcessedAsync(session, Node(id), start, end, aggId, ct);
|
|
var d = await source.ReadProcessedAsync(tag, start, end, TimeSpan.FromMilliseconds(IntervalMs), domain!.Value, ct);
|
|
report.Add(new Case($"processed.{name}", tag, w, "wire", p.Status, null, null, p.Rows));
|
|
report.Add(new Case($"processed.{name}", tag, w, "direct", "Good", null, null, Rows(d.Samples)));
|
|
CheckSame(failures, $"processed.{name} {tag} {w}", p.Rows, Rows(d.Samples));
|
|
}
|
|
foreach (var (name, aggId) in Refused)
|
|
{
|
|
var p = await ReadProcessedAsync(session, Node(id), start, end, aggId, ct);
|
|
report.Add(new Case($"processed.refused.{name}", tag, w, "wire", p.Status, null, null, p.Rows));
|
|
Check(failures, p.Status == "BadAggregateNotSupported", $"processed.{name} {tag} {w}: got {p.Status}");
|
|
}
|
|
|
|
// ---- AtTime: one stored timestamp, a midpoint between two stored samples, and the window start.
|
|
var times = new List<DateTime> { start };
|
|
if (wire.Rows.Count >= 2)
|
|
{
|
|
times.Add(wire.Rows[1].Src!.Value);
|
|
times.Add(wire.Rows[0].Src!.Value + (wire.Rows[1].Src!.Value - wire.Rows[0].Src!.Value) / 2);
|
|
}
|
|
var at = await ReadAtTimeAsync(session, Node(id), times, ct);
|
|
var atDirect = await source.ReadAtTimeAsync(tag, times, TypeCode.Double, ct);
|
|
report.Add(new Case("attime", tag, w, "wire", at.Status, string.Join(",", times.Select(t => t.ToString("o"))), null, at.Rows));
|
|
report.Add(new Case("attime", tag, w, "direct", "Good", null, null, Rows(atDirect.Samples)));
|
|
CheckSame(failures, $"attime {tag} {w}", at.Rows, Rows(atDirect.Samples));
|
|
}
|
|
}
|
|
|
|
// ---- A tag the historian does not have (#653): no data, not an error.
|
|
{
|
|
var (start, end) = windows[0];
|
|
var r = await ReadRawAsync(session, Node(missingNode), start, end, 0, returnBounds: false, ct);
|
|
report.Add(new Case("raw.missingTag", MissingTag, $"{start:o}/{end:o}", "wire", r.Status, null, null, r.Rows));
|
|
Check(failures, r.Rows.Count == 0 && !r.Status.StartsWith("Bad", StringComparison.Ordinal), $"missing tag: {r.Status} rows={r.Rows.Count}");
|
|
}
|
|
|
|
// ---- SysTimeSec raw (historiangw#29 reported server-side timeouts on 2023 R2) — recorded, not asserted.
|
|
{
|
|
var (start, _) = windows[^1];
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
var r = await ReadRawAsync(session, Node(sysTimeNode), start, start.AddSeconds(10), 0, returnBounds: false, ct);
|
|
report.Add(new Case("raw.SysTimeSec.known29", "SysTimeSec", $"{start:o}/{start.AddSeconds(10):o}", "wire", r.Status,
|
|
$"elapsedMs={sw.ElapsedMilliseconds}", null, r.Rows));
|
|
}
|
|
|
|
// ---- Events: the historian's own A&E rows for one source, wire (whole + paged) vs the backend.
|
|
{
|
|
var (start, end) = eventWindow;
|
|
var w = $"{start:o}/{end:o}";
|
|
var discovered = await source.ReadEventsAsync(null, start, end, 0, ct);
|
|
report.Add(new Case("events.unfilteredBySource", null, w, "direct", "Good",
|
|
string.Join(",", discovered.Events.GroupBy(e => e.SourceName ?? "<null>").OrderBy(g => g.Key, StringComparer.Ordinal)
|
|
.Select(g => $"{g.Key}={g.Count()}")), null));
|
|
foreach (var eventSource in eventSources)
|
|
{
|
|
var directEvents = await source.ReadEventsAsync(eventSource, start, end, 0, ct);
|
|
var directRows = directEvents.Events.Select(e => new EventRow(e.EventId, e.EventTimeUtc, e.ReceivedTimeUtc, e.SourceName,
|
|
e.Message, e.Severity)).ToList();
|
|
var all = await ReadEventsAsync(session, Node(eventSource), start, end, 0, ct);
|
|
var paged = await ReadEventsAsync(session, Node(eventSource), start, end, 1, ct);
|
|
var reverse = await ReadEventsAsync(session, Node(eventSource), end, start, 0, ct);
|
|
report.Add(new Case("events.all", eventSource, w, "wire", all.Status, $"pages={all.Pages}", null, null, all.Rows));
|
|
report.Add(new Case("events.paged1", eventSource, w, "wire", paged.Status, $"pages={paged.Pages}", null, null, paged.Rows));
|
|
report.Add(new Case("events.reverse", eventSource, w, "wire", reverse.Status, $"pages={reverse.Pages}", null, null, reverse.Rows));
|
|
report.Add(new Case("events.all", eventSource, w, "direct", "Good", null, null, null, directRows));
|
|
if (eventSource == eventSources[0])
|
|
{
|
|
Check(failures, directRows.Count > 0, $"events {eventSource} {w}: expected stored events");
|
|
// #882: the object's folder serves its own events AND its attribute alarms (<Object>.<Attribute>) —
|
|
// exactly the unfiltered read's rows named the object or <object>.*, on the '.' boundary.
|
|
var expectedIds = discovered.Events
|
|
.Where(e => e.SourceName == eventSource || (e.SourceName?.StartsWith(eventSource + ".", StringComparison.Ordinal) ?? false))
|
|
.Select(e => e.EventId).OrderBy(id => id, StringComparer.Ordinal).ToList();
|
|
var wireIds = all.Rows.Select(r => r.Id).OrderBy(id => id, StringComparer.Ordinal).ToList();
|
|
Check(failures, wireIds.SequenceEqual(expectedIds),
|
|
$"events {eventSource} {w}: wire {wireIds.Count} events vs {expectedIds.Count} named {eventSource} or {eventSource}.* (#882)");
|
|
var attributeEvents = all.Rows.Count(r => r.Source?.StartsWith(eventSource + ".", StringComparison.Ordinal) ?? false);
|
|
report.Add(new Case("events.attributeOf", eventSource, w, "wire", all.Status,
|
|
$"attributeEvents={attributeEvents} objectEvents={all.Rows.Count - attributeEvents}", null));
|
|
}
|
|
CheckSameEvents(failures, $"events.all {eventSource}", all.Rows, directRows);
|
|
CheckSameEvents(failures, $"events.paged1 {eventSource}", paged.Rows, directRows);
|
|
CheckSameEvents(failures, $"events.reverse {eventSource}", reverse.Rows, directRows.AsEnumerable().Reverse().ToList());
|
|
}
|
|
}
|
|
|
|
// ---- #886: a real equipment folder serves the AVEVA history of every Galaxy object behind its native conditions.
|
|
{
|
|
var (start, end) = eventWindow;
|
|
var w = $"{start:o}/{end:o}";
|
|
var objects = galaxyRefs.Select(r => GalaxyAttributeRef.ObjectNameOf(r)!).Distinct(StringComparer.Ordinal).ToList();
|
|
var discovered = await source.ReadEventsAsync(null, start, end, 0, ct);
|
|
bool OfAnObject(string? s) => s is not null && objects.Any(o => s == o || s.StartsWith(o + ".", StringComparison.Ordinal));
|
|
var expected = discovered.Events.Where(e => OfAnObject(e.SourceName)).ToList();
|
|
// The backend's own mapping of each event (per object, as the folder reads them): severity and AVEVA's condition.
|
|
var direct = new Dictionary<string, HistoricalEvent>(StringComparer.Ordinal);
|
|
foreach (var o in objects)
|
|
foreach (var e in (await source.ReadEventsAsync(o, start, end, 0, ct)).Events) direct.TryAdd(e.EventId, e);
|
|
var folderOwn = await source.ReadEventsAsync(GalaxyEquipment, start, end, 0, ct);
|
|
|
|
var uns = new NodeId(GalaxyEquipment, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri));
|
|
var all = await ReadGalaxyEventsAsync(session, uns, start, end, 0, ct);
|
|
var paged1 = await ReadGalaxyEventsAsync(session, uns, start, end, 1, ct);
|
|
var paged7 = await ReadGalaxyEventsAsync(session, uns, start, end, 7, ct);
|
|
var reverse = await ReadGalaxyEventsAsync(session, uns, end, start, 0, ct);
|
|
var reverse5 = await ReadGalaxyEventsAsync(session, uns, end, start, 5, ct);
|
|
|
|
var bySource = string.Join(",", all.Rows.GroupBy(r => r.Event.Source ?? "<null>").OrderBy(g => g.Key, StringComparer.Ordinal)
|
|
.Select(g => $"{g.Key}={g.Count()}"));
|
|
var resolved = all.Rows.Count(r => r.SourceNode is not null);
|
|
report.Add(new Case("events886.folderOwnSourceOnly", GalaxyEquipment, w, "direct", "Good",
|
|
$"events={folderOwn.Events.Count} (what the folder read before #886)", null));
|
|
report.Add(new Case("events886.all", GalaxyEquipment, w, "wire", all.Status,
|
|
$"pages={all.Pages} objects={string.Join('+', objects)} bySource={bySource} sourceNodeResolved={resolved}", null, null,
|
|
all.Rows.Select(r => r.Event).ToList()));
|
|
report.Add(new Case("events886.paged1", GalaxyEquipment, w, "wire", paged1.Status, $"pages={paged1.Pages}", null, null,
|
|
paged1.Rows.Select(r => r.Event).ToList()));
|
|
report.Add(new Case("events886.paged7", GalaxyEquipment, w, "wire", paged7.Status, $"pages={paged7.Pages}", null, null,
|
|
paged7.Rows.Select(r => r.Event).ToList()));
|
|
report.Add(new Case("events886.reverse", GalaxyEquipment, w, "wire", reverse.Status, $"pages={reverse.Pages}", null, null,
|
|
reverse.Rows.Select(r => r.Event).ToList()));
|
|
report.Add(new Case("events886.reverse5", GalaxyEquipment, w, "wire", reverse5.Status, $"pages={reverse5.Pages}", null, null,
|
|
reverse5.Rows.Select(r => r.Event).ToList()));
|
|
report.Add(new Case("events886.conditions", GalaxyEquipment, w, "wire", all.Status,
|
|
string.Join(";", all.Rows.Where(r => r.SourceNode is not null).GroupBy(r => (r.SourceNode, r.ConditionName))
|
|
.Select(g => $"{g.Key.SourceNode}|{g.Key.ConditionName}={g.Count()}")), null));
|
|
|
|
foreach (var o in objects)
|
|
Check(failures, expected.Any(e => e.SourceName == o || e.SourceName!.StartsWith(o + ".", StringComparison.Ordinal)),
|
|
$"events886 {o} {w}: expected stored events for every object behind the folder");
|
|
Check(failures, folderOwn.Events.Count == 0, $"events886: the folder id {GalaxyEquipment} must name no AVEVA source");
|
|
var expectedIds = expected.Select(e => e.EventId).OrderBy(id => id, StringComparer.Ordinal).ToList();
|
|
Check(failures, all.Rows.Select(r => r.Event.Id).OrderBy(id => id, StringComparer.Ordinal).SequenceEqual(expectedIds),
|
|
$"events886 {w}: wire {all.Rows.Count} events vs {expectedIds.Count} of {string.Join('/', objects)} or their attributes");
|
|
Check(failures, all.Rows.Zip(all.Rows.Skip(1)).All(p => p.First.Event.Time <= p.Second.Event.Time),
|
|
"events886: a forward read is in time order across objects");
|
|
var forwardIds = all.Rows.Select(r => r.Event.Id).ToList();
|
|
Check(failures, paged1.Rows.Select(r => r.Event.Id).SequenceEqual(forwardIds), "events886: 1-event pages equal the whole read");
|
|
Check(failures, paged7.Rows.Select(r => r.Event.Id).SequenceEqual(forwardIds), "events886: 7-event pages equal the whole read");
|
|
var reverseIds = expected.OrderByDescending(e => e.EventTimeUtc).ThenByDescending(e => e.EventId, StringComparer.Ordinal)
|
|
.Select(e => e.EventId).ToList();
|
|
Check(failures, reverse.Rows.Select(r => r.Event.Id).SequenceEqual(reverseIds),
|
|
"events886: a reverse read is newest first (ties by EventId, descending)");
|
|
Check(failures, reverse5.Rows.Select(r => r.Event.Id).SequenceEqual(reverseIds), "events886: 5-event reverse pages equal the reverse read");
|
|
foreach (var r in all.Rows)
|
|
{
|
|
if (!direct.TryGetValue(r.Event.Id!, out var d)) { failures.Add($"events886: {r.Event.Id} not in the per-object read"); continue; }
|
|
Check(failures, r.Event.Severity == d.Severity, $"events886: {r.Event.Id} severity {r.Event.Severity} vs backend {d.Severity}");
|
|
var live = d.ConditionReference is { } cr && galaxyConditions.TryGetValue(cr, out var c) ? c : ((string RawPath, string Name)?)null;
|
|
Check(failures, r.SourceNode == live?.RawPath,
|
|
$"events886: {r.Event.Id} ({d.ConditionReference}) SourceNode {r.SourceNode ?? "null"} vs {live?.RawPath ?? "null"}");
|
|
if (live is { } lc)
|
|
Check(failures, r.ConditionName == lc.Name, $"events886: {r.Event.Id} ConditionName {r.ConditionName} vs {lc.Name}");
|
|
}
|
|
|
|
Check(failures, resolved > 0, "events886: at least one row resolves to its live condition");
|
|
}
|
|
|
|
// ---- HistoryUpdate: refused by the server (no updater, no HistoryWrite bit). Sandbox-bound node only.
|
|
if (sandboxNode is null)
|
|
{
|
|
report.Add(new Case("historyUpdate", null, null, "wire", "SKIPPED", "HISTGW_WRITE_SANDBOX_TAG not set", null));
|
|
}
|
|
else
|
|
{
|
|
var t = DateTime.UtcNow.AddHours(-1);
|
|
var insert = new UpdateDataDetails { NodeId = Node(sandboxNode), PerformInsertReplace = PerformUpdateType.Insert };
|
|
insert.UpdateValues += new DataValue(Variant.From(801.0), StatusCodes.Good, t, t);
|
|
var del = new DeleteRawModifiedDetails { NodeId = Node(sandboxNode), IsDeleteModified = false, StartTime = t, EndTime = t.AddSeconds(1) };
|
|
foreach (var (name, details) in new (string, IEncodeable)[] { ("insert", insert), ("deleteRaw", del) })
|
|
{
|
|
var response = await session.HistoryUpdateAsync(null, new ExtensionObject[] { new(details) }, ct);
|
|
var result = response.Results.ToList().ShouldHaveSingleItem();
|
|
var status = Name(result.StatusCode);
|
|
report.Add(new Case($"historyUpdate.{name}", sandboxTag, null, "wire", status,
|
|
string.Join(",", result.OperationResults.ToList().Select(Name)), null));
|
|
Check(failures, status == "BadHistoryOperationUnsupported", $"historyUpdate.{name}: got {status}");
|
|
}
|
|
}
|
|
|
|
var outDir = Env("OTOPCUA_AVEVA_GATE_OUT") ?? Path.GetTempPath();
|
|
Directory.CreateDirectory(outDir);
|
|
var file = Path.Combine(outDir, $"aveva-gate-{Sanitize(label)}.json");
|
|
report.Failures = failures;
|
|
await File.WriteAllTextAsync(file, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }), ct);
|
|
TestContext.Current.TestOutputHelper?.WriteLine($"#801 AVEVA gate report: {file} ({report.Cases.Count} cases, {failures.Count} failures)");
|
|
|
|
failures.ShouldBeEmpty();
|
|
await session.CloseAsync(ct);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ wire helpers
|
|
|
|
private static async Task<WireRead> ReadRawAsync(ISession session, NodeId node, DateTime start, DateTime end, uint max,
|
|
bool returnBounds, CancellationToken ct)
|
|
{
|
|
var details = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = start, EndTime = end, NumValuesPerNode = max, IsReadModified = false, ReturnBounds = returnBounds,
|
|
};
|
|
var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
|
await ReleaseAsync(session, details, node, cp, ct);
|
|
return new WireRead(status, rows, 1);
|
|
}
|
|
|
|
private static async Task<WireRead> ReadRawPagedAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page,
|
|
CancellationToken ct)
|
|
{
|
|
var details = new ReadRawModifiedDetails
|
|
{
|
|
StartTime = start, EndTime = end, NumValuesPerNode = page, IsReadModified = false, ReturnBounds = false,
|
|
};
|
|
var all = new List<Row>();
|
|
var pages = 0;
|
|
ByteString cp = default;
|
|
string status;
|
|
do
|
|
{
|
|
(status, var rows, cp) = await HistoryReadAsync(session, details, node, cp, ct);
|
|
all.AddRange(rows);
|
|
pages++;
|
|
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
|
}
|
|
while (!cp.IsEmpty && pages < 10_000);
|
|
return new WireRead(status, all, pages);
|
|
}
|
|
|
|
private static async Task<string> ReadModifiedAsync(ISession session, NodeId node, DateTime start, DateTime end, CancellationToken ct)
|
|
{
|
|
var details = new ReadRawModifiedDetails { StartTime = start, EndTime = end, NumValuesPerNode = 0, IsReadModified = true, ReturnBounds = false };
|
|
var (status, _, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
|
await ReleaseAsync(session, details, node, cp, ct);
|
|
return status;
|
|
}
|
|
|
|
private static async Task<WireRead> ReadProcessedAsync(ISession session, NodeId node, DateTime start, DateTime end, NodeId aggregate,
|
|
CancellationToken ct)
|
|
{
|
|
var details = new ReadProcessedDetails
|
|
{
|
|
StartTime = start, EndTime = end, ProcessingInterval = IntervalMs,
|
|
AggregateType = new List<NodeId> { aggregate },
|
|
AggregateConfiguration = new AggregateConfiguration { UseServerCapabilitiesDefaults = true },
|
|
};
|
|
var all = new List<Row>();
|
|
var pages = 0;
|
|
ByteString cp = default;
|
|
string status;
|
|
do
|
|
{
|
|
(status, var rows, cp) = await HistoryReadAsync(session, details, node, cp, ct);
|
|
all.AddRange(rows);
|
|
pages++;
|
|
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
|
}
|
|
while (!cp.IsEmpty && pages < 1000);
|
|
return new WireRead(status, all, pages);
|
|
}
|
|
|
|
private static async Task<WireRead> ReadAtTimeAsync(ISession session, NodeId node, IReadOnlyList<DateTime> times, CancellationToken ct)
|
|
{
|
|
var details = new ReadAtTimeDetails { ReqTimes = times.Select(t => (DateTimeUtc)t).ToList(), UseSimpleBounds = true };
|
|
var (status, rows, cp) = await HistoryReadAsync(session, details, node, default, ct);
|
|
await ReleaseAsync(session, details, node, cp, ct);
|
|
return new WireRead(status, rows, 1);
|
|
}
|
|
|
|
private static async Task<(string Status, List<Row> Rows, ByteString Cp)> HistoryReadAsync(
|
|
ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
|
|
{
|
|
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
|
var result = response.Results.ToList().ShouldHaveSingleItem();
|
|
var rows = new List<Row>();
|
|
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryData data)
|
|
rows.AddRange(data.DataValues.ToList().Select(dv => new Row(
|
|
Fmt(dv.ClrValue()), dv.StatusCode.Code, Ts(dv.SourceTimestamp), Ts(dv.ServerTimestamp))));
|
|
return (Name(result.StatusCode), rows, result.ContinuationPoint);
|
|
}
|
|
|
|
private static async Task ReleaseAsync(ISession session, IEncodeable details, NodeId node, ByteString cp, CancellationToken ct)
|
|
{
|
|
if (cp.IsEmpty) return;
|
|
await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, true,
|
|
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
|
}
|
|
|
|
private static async Task<EventRead> ReadEventsAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page,
|
|
CancellationToken ct)
|
|
{
|
|
var filter = new EventFilter();
|
|
foreach (var field in new[] { "EventId", "Time", "ReceiveTime", "SourceName", "Message", "Severity" })
|
|
filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(field)) { AttributeId = Attributes.Value };
|
|
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, Filter = filter };
|
|
var rows = new List<EventRow>();
|
|
var pages = 0;
|
|
ByteString cp = default;
|
|
string status;
|
|
do
|
|
{
|
|
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
|
var result = response.Results.ToList().ShouldHaveSingleItem();
|
|
status = Name(result.StatusCode);
|
|
cp = result.ContinuationPoint;
|
|
pages++;
|
|
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
|
|
foreach (var e in he.Events.ToList())
|
|
{
|
|
var f = e.EventFields.ToList();
|
|
rows.Add(new EventRow(
|
|
f[0].ClrValue() is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].ClrValue()),
|
|
f[1].ClrValue() as DateTime? ?? default, f[2].ClrValue() as DateTime? ?? default,
|
|
f[3].ClrValue() as string, f[4].ClrValue() is LocalizedText lt ? lt.Text : Fmt(f[4].ClrValue()),
|
|
Convert.ToUInt16(f[5].ClrValue() ?? 0, CultureInfo.InvariantCulture)));
|
|
}
|
|
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
|
}
|
|
while (!cp.IsEmpty && pages < 10_000);
|
|
return new EventRead(status, rows, pages);
|
|
}
|
|
|
|
/// <summary>#886: an events read that also returns the SourceNode and ConditionName (paged by continuation points).</summary>
|
|
private static async Task<GalaxyEventRead> ReadGalaxyEventsAsync(ISession session, NodeId node, DateTime start, DateTime end, uint page,
|
|
CancellationToken ct)
|
|
{
|
|
var filter = new EventFilter();
|
|
foreach (var field in new[] { "EventId", "Time", "ReceiveTime", "SourceName", "Message", "Severity", "SourceNode", "ConditionName" })
|
|
{
|
|
var type = field == "ConditionName" ? ObjectTypeIds.ConditionType : ObjectTypeIds.BaseEventType;
|
|
filter.SelectClauses += new SimpleAttributeOperand(type, new QualifiedName(field)) { AttributeId = Attributes.Value };
|
|
}
|
|
|
|
var details = new ReadEventDetails { StartTime = start, EndTime = end, NumValuesPerNode = page, Filter = filter };
|
|
var rows = new List<GalaxyEventRow>();
|
|
var pages = 0;
|
|
ByteString cp = default;
|
|
string status;
|
|
do
|
|
{
|
|
var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false,
|
|
new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = cp } }, ct);
|
|
var result = response.Results.ToList().ShouldHaveSingleItem();
|
|
status = Name(result.StatusCode);
|
|
cp = result.ContinuationPoint;
|
|
pages++;
|
|
if (!result.HistoryData.IsNull && ExtensionObject.ToEncodeable(result.HistoryData) is HistoryEvent he)
|
|
foreach (var e in he.Events.ToList())
|
|
{
|
|
var f = e.EventFields.ToList();
|
|
rows.Add(new GalaxyEventRow(
|
|
new EventRow(
|
|
f[0].ClrValue() is byte[] id ? System.Text.Encoding.UTF8.GetString(id) : Fmt(f[0].ClrValue()),
|
|
f[1].ClrValue() as DateTime? ?? default, f[2].ClrValue() as DateTime? ?? default,
|
|
f[3].ClrValue() as string, f[4].ClrValue() is LocalizedText lt ? lt.Text : Fmt(f[4].ClrValue()),
|
|
Convert.ToUInt16(f[5].ClrValue() ?? 0, CultureInfo.InvariantCulture)),
|
|
f[6].ClrValue() is NodeId sn && !sn.IsNull ? sn.IdentifierAsString : null,
|
|
f[7].ClrValue() as string));
|
|
}
|
|
if (status.StartsWith("Bad", StringComparison.Ordinal)) break;
|
|
}
|
|
while (!cp.IsEmpty && pages < 10_000);
|
|
return new GalaxyEventRead(status, rows, pages);
|
|
}
|
|
|
|
private static async Task<Dictionary<string, string?>> ReadAttributesAsync(ISession session, NodeId node, CancellationToken ct)
|
|
{
|
|
var ids = new (string Name, uint Id)[]
|
|
{
|
|
("Historizing", Attributes.Historizing), ("AccessLevel", Attributes.AccessLevel),
|
|
("UserAccessLevel", Attributes.UserAccessLevel), ("DataType", Attributes.DataType),
|
|
};
|
|
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
|
ids.Select(a => new ReadValueId { NodeId = node, AttributeId = a.Id }).ToArray(), ct);
|
|
var results = response.Results.ToList();
|
|
return ids.Select((a, i) => (a.Name, Value: Fmt(results[i].ClrValue())))
|
|
.ToDictionary(x => x.Name, x => x.Value);
|
|
}
|
|
|
|
private static async Task<Dictionary<string, string?>> ReadHistoryCapabilitiesAsync(ISession session, CancellationToken ct)
|
|
{
|
|
var caps = new Dictionary<string, string?>(StringComparer.Ordinal);
|
|
var children = await BrowseAsync(session, ObjectIds.HistoryServerCapabilities, ReferenceTypeIds.HierarchicalReferences, ct);
|
|
var vars = children.Where(r => r.NodeClass == NodeClass.Variable).ToList();
|
|
if (vars.Count > 0)
|
|
{
|
|
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither,
|
|
vars.Select(r => new ReadValueId { NodeId = ExpandedNodeId.ToNodeId(r.NodeId, session.NamespaceUris), AttributeId = Attributes.Value }).ToArray(), ct);
|
|
var results = response.Results.ToList();
|
|
for (var i = 0; i < vars.Count; i++) caps[vars[i].BrowseName.Name!] = Fmt(results[i].ClrValue());
|
|
}
|
|
var aggs = await BrowseAsync(session, ObjectIds.HistoryServerCapabilities_AggregateFunctions, ReferenceTypeIds.Organizes, ct);
|
|
caps["AggregateFunctions"] = string.Join(",", aggs.Select(r => r.BrowseName.Name).OrderBy(n => n, StringComparer.Ordinal));
|
|
return caps;
|
|
}
|
|
|
|
private static async Task<List<ReferenceDescription>> BrowseAsync(ISession session, NodeId node, NodeId referenceType, CancellationToken ct)
|
|
{
|
|
var response = await session.BrowseAsync(null, null, 0,
|
|
new BrowseDescription[]
|
|
{
|
|
new()
|
|
{
|
|
NodeId = node, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = referenceType,
|
|
IncludeSubtypes = true, NodeClassMask = 0, ResultMask = (uint)BrowseResultMask.All,
|
|
},
|
|
}, ct);
|
|
return response.Results[0].References.ToList();
|
|
}
|
|
|
|
private static DateTime? Ts(DateTimeUtc t) => (DateTime)t == DateTime.MinValue ? null : DateTime.SpecifyKind((DateTime)t, DateTimeKind.Utc);
|
|
|
|
private static string Name(StatusCode code) =>
|
|
code.SymbolicId is { Length: > 0 } s ? s : $"0x{code.Code:X8}";
|
|
|
|
// ------------------------------------------------------------------ SDK-neutral comparison + report
|
|
|
|
private static List<Row> Rows(IReadOnlyList<DataValueSnapshot> samples) =>
|
|
samples.Select(s => new Row(Fmt(s.Value), s.StatusCode, s.SourceTimestampUtc, s.ServerTimestampUtc)).ToList();
|
|
|
|
private static string? Fmt(object? value) => value switch
|
|
{
|
|
null => null,
|
|
double d => d.ToString("R", CultureInfo.InvariantCulture),
|
|
float f => ((double)f).ToString("R", CultureInfo.InvariantCulture),
|
|
DateTime t => t.ToUniversalTime().ToString("o", CultureInfo.InvariantCulture),
|
|
IFormattable f => $"{value.GetType().Name}:{f.ToString(null, CultureInfo.InvariantCulture)}",
|
|
_ => $"{value.GetType().Name}:{value}",
|
|
};
|
|
|
|
private static void CheckSame(List<string> failures, string what, IReadOnlyList<Row> wire, IReadOnlyList<Row> reference)
|
|
{
|
|
if (wire.Count != reference.Count)
|
|
{
|
|
failures.Add($"{what}: wire {wire.Count} rows vs reference {reference.Count}");
|
|
return;
|
|
}
|
|
for (var i = 0; i < wire.Count; i++)
|
|
{
|
|
var (a, b) = (wire[i], reference[i]);
|
|
if (a.V != b.V || a.Status != b.Status || a.Src != b.Src)
|
|
{
|
|
failures.Add($"{what}[{i}]: wire ({a.V}, 0x{a.Status:X8}, {a.Src:o}) vs reference ({b.V}, 0x{b.Status:X8}, {b.Src:o})");
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static void CheckSameEvents(List<string> failures, string what, IReadOnlyList<EventRow> wire, IReadOnlyList<EventRow> reference)
|
|
{
|
|
var a = wire.Select(e => (e.Id, e.Time, e.Severity)).ToList();
|
|
var b = reference.Select(e => (e.Id, e.Time, e.Severity)).ToList();
|
|
if (!a.SequenceEqual(b)) failures.Add($"{what}: wire {a.Count} events vs reference {b.Count} (first difference at {FirstDiff(a, b)})");
|
|
}
|
|
|
|
private static int FirstDiff<T>(IReadOnlyList<T> a, IReadOnlyList<T> b)
|
|
{
|
|
for (var i = 0; i < Math.Min(a.Count, b.Count); i++) if (!EqualityComparer<T>.Default.Equals(a[i], b[i])) return i;
|
|
return Math.Min(a.Count, b.Count);
|
|
}
|
|
|
|
private static void Check(List<string> failures, bool ok, string message)
|
|
{
|
|
if (!ok) failures.Add(message);
|
|
}
|
|
|
|
private static List<(DateTime Start, DateTime End)> ParseWindows(string spec) =>
|
|
spec.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Select(w =>
|
|
{
|
|
var parts = w.Split('/');
|
|
return (Utc(parts[0]), Utc(parts[1]));
|
|
}).ToList();
|
|
|
|
private static DateTime Utc(string s) =>
|
|
DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal);
|
|
|
|
private static string? Env(string name) => Environment.GetEnvironmentVariable(name) is { Length: > 0 } v ? v.Trim() : null;
|
|
|
|
private static string Sanitize(string s) => string.Concat(s.Select(c => char.IsLetterOrDigit(c) || c is '.' or '-' ? c : '_'));
|
|
|
|
private sealed record Row(string? V, uint Status, DateTime? Src, DateTime? Srv);
|
|
|
|
private sealed record EventRow(string? Id, DateTime Time, DateTime Received, string? Source, string? Message, ushort Severity);
|
|
|
|
private sealed record WireRead(string Status, List<Row> Rows, int Pages);
|
|
|
|
private sealed record EventRead(string Status, List<EventRow> Rows, int Pages);
|
|
|
|
private sealed record GalaxyEventRow(EventRow Event, string? SourceNode, string? ConditionName);
|
|
|
|
private sealed record GalaxyEventRead(string Status, List<GalaxyEventRow> Rows, int Pages);
|
|
|
|
private sealed record Case(
|
|
string Name, string? Tag, string? Window, string Path, string Status, string? Note,
|
|
Dictionary<string, string?>? Attributes, List<Row>? Rows = null, List<EventRow>? Events = null);
|
|
|
|
private sealed class Report(string label, string endpoint)
|
|
{
|
|
public string Label { get; } = label;
|
|
public string Endpoint { get; } = endpoint;
|
|
public string SdkServerAssembly { get; } = typeof(Opc.Ua.Server.StandardServer).Assembly.GetName().Version?.ToString() ?? "?";
|
|
public DateTime RunUtc { get; } = DateTime.UtcNow;
|
|
public List<Case> Cases { get; } = [];
|
|
public List<string> Failures { get; set; } = [];
|
|
public void Add(Case c) => Cases.Add(c);
|
|
}
|
|
}
|