3a312ffdd1
v2-ci / unit-tests (push) Has been cancelled
A notifier folder read one event source, its NodeId identifier (a RawPath or an EquipmentId). AVEVA stores a Galaxy object's A&E history under the object's tag_name (Source_Object), so AVEVA's own alarm and event history reached no production folder. Its lower-case property keys were also missed, so every row read Severity 1 with no ConditionName. Sources. A folder's event history now reads its own source plus the Galaxy object of every native condition that fires on it live: those parented under it and those WireAlarmNotifiers wired to it. Live and history use the same membership. The object comes from the condition's Galaxy alarm reference, its raw tag's attributeRef, up to the first '.': - The composer reads it with the driver's own parser (GalaxyAttributeRef, shared with GalaxyDriver), only for a GalaxyMxGateway tag with an alarm (RawTagPlan.NativeAlarmReference, part of plan equality). - The applier binds it after materialising the condition (IOpcUaAddressSpaceSink.BindNativeAlarmHistory, forwarded by the deferred sink). A rebuild or the condition's removal forgets it. Each object is read with the #882 rule: its own events and <Object>.<Attribute> rows, never a prefix sibling. Fan-out. historiangw's ReadEvents filter is a single predicate, so there is one request per source; the wire contract is unchanged. EventSourceFanOut merges the reads as one source: - order is (time, source position, arrival), reversed for a reverse read; ties are never re-sorted by EventId, so the boundary skip stays stable; - duplicates by EventId are dropped and the merge is cut to the cap; - a truncated source bounds the page at the time it stopped. Paging, continuation points (bound to the whole source set), the walked-back reverse read and the tie-cluster over-fetch are unchanged. A folder with one source is read exactly as before. ACL. Every source is read inside the folder's own HistoryRead, after AuthorizeHistoryRead (#826). A denied folder reads nothing and fails closed. Mapping (EventMapper, property names now case-insensitive): - Severity: an AVEVA row's priority goes through GalaxyAlarmPriority, the table live Galaxy alarms use (MxAccessSeverityMapper now delegates to it), so history matches live. Otherwise severity (OtOpcUa's own rows, which AVEVA stores lower-cased), clamped. AVEVA's own 1-4 severity is ignored. The table ranks a higher priority as more severe, which inverts AVEVA's convention: filed as #887. - An AVEVA row whose source_conditionvariable names a native condition that fires on the folder takes that condition's SourceNode and live ConditionName. Otherwise ConditionName is alarmname or the condition variable's attribute. - alarm_id/alarmid map to AlarmId. Tests: - GalaxyEquipmentEventHistoryWireTests (4, real SDK session, production Gateway backend, AVEVA-shaped fake gateway): equipment EqA backed by objects Oven_007 + OvenDoor_07. Covers object and attribute events from both objects, the prefix sibling excluded, cross-object and in-object ties, forward paging at 1-4 a page, reverse whole and paged, SourceNode/ConditionName/Severity, the raw device folder, ACL deny with no backend call plus a granted positive control, and a rebuild forgetting the bindings. - EventSourceFanOutTests (8), EventMapper AVEVA cases (11), GalaxyAttributeRefTests (16), a composer test and an applier ordering test. - Mutation-checked, 17 mutants, all killed. Covered: parent-only membership, no scope, a case-sensitive condition match, no condition rewrite, no truncation bound, EventId tie order, no sort, no cap, no stop at the bound, unreversed source order, no dedupe, no '.' boundary, priority ignored, case-sensitive keys, no applier bind, no deferred forward, and a composer that ignores the driver type. The forget-on-rematerialise guard survives as belt-and-braces: the rebuild clear covers it. - Live, read-only, no WinRM, vd03 HistorianGateway ea9f21c: equipment folder EQ-886-ZipsenLeft (objects DevWinPlatform + Left_001). It read 0 events before and 92 now, equal to the unfiltered read's rows for both objects. 1/7-event pages and reverse whole/5-event pages agree, and 73 rows resolve to their live condition. diff_runs vs the #882 run: 221/226 identical. The 5 that differ are expected: DevWinPlatform Severity 1 -> 750/1000, and #881 capabilities. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi
750 lines
48 KiB
C#
750 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 ten per-sample aggregates the Gateway backend serves (#681) and a set it must refuse (time-weighted / 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),
|
|
];
|
|
|
|
private static readonly (string Name, NodeId Id)[] Refused =
|
|
[
|
|
("TimeAverage", ObjectIds.AggregateFunction_TimeAverage),
|
|
("Total", ObjectIds.AggregateFunction_Total),
|
|
("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 ten, 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);
|
|
}
|
|
}
|