diff --git a/docs/Historian.md b/docs/Historian.md index 6d394d7ab..e83742dbc 100644 --- a/docs/Historian.md +++ b/docs/Historian.md @@ -630,7 +630,25 @@ OPC UA client can discover historized capability from the node's attributes. owns at least one alarm condition is already an event notifier; the server registers a `sourceName` (the equipment id) for each such folder and maps event history reads to the HistorianGateway using that source. (Alarm-history `ReadEvents` requires the gateway running -`RuntimeDb:EventReadsEnabled=true`.) Event-field projection supports the standard +`RuntimeDb:EventReadsEnabled=true`.) + +**A notifier's event history includes its attributes' events (Gitea #882, Gateway backend).** The gateway filters a +per-source read on AVEVA's `Source_Object` and reports each row's `SourceName` as `Source_ProcessVariable`: an +object's own events (`System.Stop` / `System.Start`) come back as `DevWinPlatform`, and an alarm on one of its +attributes as `DevWinPlatform.CPULoadAvg`. Both are returned by the one request for `DevWinPlatform`, and both are +served: a notifier whose source is `X` serves the events whose `SourceName` is `X` or starts with `X.`. The match is +on the `.` boundary, so `DevWinPlatform` never serves `DevWinPlatform2` or `DevWinPlatform2.CPULoadAvg`. This matches +live delivery, where an attribute alarm's condition fires on the same folder as the object's own events. Each event +keeps its own `SourceName` (the attribute's); its `SourceNode` is the condition its stored alarm id names, so an AVEVA +row (no OtOpcUa alarm id) has a null `SourceNode`. The attribute events are part of the notifier's own read, so they +are governed by that notifier's HistoryRead verdict: with `Security:Acl:Enabled`, a session denied the folder gets +`BadUserAccessDenied` and the backend is never called. No attribute-level notifier is exposed; a notifier whose +source is itself an attribute name (`DevWinPlatform.CPULoadAvg`) still answers `GoodNoData` from the gateway, whose +`Source_Object` is the object. The SqlServer and QuestDB backends match the source exactly, as before: they hold only +OtOpcUa's own alarm events. ⚠️ A production folder's source is its NodeId identifier (a RawPath or an EquipmentId), +not a Galaxy object name, so AVEVA's own events reach a folder only where those coincide (follow-up [#886](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/886)). + +Event-field projection supports the standard `BaseEventType` select clauses — `EventId`, `SourceName`, `Time`, `ReceiveTime`, `Message`, `Severity` — plus (client-visible: these used to be null): diff --git a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md index b11e99e63..9e80333a5 100644 --- a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md +++ b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801.md @@ -45,7 +45,7 @@ SourceTimestamp), plus the SDK 1 run (diffed) and, for raw, SQL and histgw-cli. | Processed, refused | TimeAverage, Total, TimeAverage2, Interpolative, PercentGood, DurationGood, StandardDeviationSample | `BadAggregateNotSupported` | SDK 1: identical. Native time-weighted is still off; no string backfill. | **PASS** (no new aggregates) | | AtTime | window start, a stored time, a midpoint | Stored value exact (Good). Midpoints are linear + Interpolated `0x00000402`. | Backend: identical. SDK 1: identical. | **PASS** | | Events | `ReadEventDetails` on folder `DevWinPlatform`: all, 1-event pages, reverse | 2 events, `System.Stop` 11:32:35.095Z / `System.Start` 11:43:46.563Z, with EventId, Time, ReceiveTime, SourceName, Message and Severity. Paging takes 2 pages. Reverse returns the newest first. | Backend: identical. SDK 1: identical. | **PASS** | -| Events, attribute alarm | same, folder `DevWinPlatform.CPULoadAvg` | `GoodNoData`. The gateway reports these 70 events with SourceName `DevWinPlatform.CPULoadAvg` in an unfiltered read, but a per-source read for that name returns none (Source_Object is `DevWinPlatform`). | SDK 1: identical | **PASS** (pre-existing gap, see Findings) | +| Events, attribute alarm | same, folder `DevWinPlatform.CPULoadAvg` | `GoodNoData`. The gateway reports these 70 events with SourceName `DevWinPlatform.CPULoadAvg` in an unfiltered read, but a per-source read for that name returns none (Source_Object is `DevWinPlatform`). | SDK 1: identical | **PASS** (pre-existing gap, see Findings; fixed by #882, see below) | | HistoryUpdate | Insert and DeleteRaw on the `HistGW.LiveTest.Sandbox`-bound node | `BadHistoryOperationUnsupported` on both | SDK 1: identical. No updater, no backend call. | **PASS** (unsupported preserved) | | Node metadata | Historizing, AccessLevel, UserAccessLevel | `true`, `5` (CurrentRead + HistoryRead, **no HistoryWrite**), `5`, DataType `i=11` | SDK 1: identical | **PASS** | | History capabilities | `HistoryServerCapabilities` children + AggregateFunctions | AggregateFunctions = the Gateway's ten. Every boolean capability flag is `false` (incl. AccessHistoryData/Events). `MaxReturnDataValues=10000`, `MaxReturnEventValues=0`. | SDK 1: identical | **PASS** (existing metadata preserved, see Findings) | @@ -63,11 +63,31 @@ ServerTimestamp on history values is the read time (`now`) on both builds. Its p (e.g. `DevWinPlatform.CPULoadAvg`), but a per-source read with that name returns nothing. Reading by `Source_Object` returns only the rows whose SourceName equals it: OtOpcUa's client-side filter drops the rest. Native AVEVA alarms are therefore invisible through OtOpcUa's event history unless their object-level name matches. This is a gateway/consumer contract gap, - identical on SDK 1. It is a candidate follow-up, not filed. + identical on SDK 1. Filed as [#882](https://gitea.dohertylan.com/dohertj2/lmxopcua/issues/882) and fixed in OtOpcUa; see + [#882 re-run](#882-re-run-2026-11-12). 3. **historiangw `histgw-cli read-raw` aborts on a stored NULL.** Its JSON writer cannot emit NaN. This is a CLI-only issue: OtOpcUa maps the same row to null + Bad. 4. **historiangw#29 (SysTimeSec raw timeout) did not reproduce** against the `ea9f21c` gateway. +## #882 re-run (2026-11-12) + +The same harness, with the #882 fix (a folder serves its object's events and its attribute alarms, `.`, +matched on the `.` boundary), against the same vd03 HistorianGateway `ea9f21c`, read-only, no WinRM, with the same API key +read over SMB and never printed. + +- Folder `DevWinPlatform`: **72 events** (2 object events plus 70 `DevWinPlatform.CPULoadAvg` attribute alarms), the same + 72 on the whole read, on 1-event pages (72 pages) and on the reverse read, each with its own SourceName. Before: 2. +- The new #882 check passes: the wire IDs equal exactly the unfiltered read's rows named `DevWinPlatform` or + `DevWinPlatform.*`. The unfiltered read holds no `DevWinPlatform` sibling on this DEV box, so the boundary is proved + by the deterministic tests, not here. +- Folder `DevWinPlatform.CPULoadAvg` stays `GoodNoData`: the gateway filters on `Source_Object`, which is the object, and + no attribute-level notifier is exposed. +- `diff_runs.py` against `aveva-gate-sdk2-d46044f5`: 225 shared cases, **221 identical**; the 4 that differ are exactly the + `DevWinPlatform` event cases (2 → 72 events). One case is new (`events.attributeOf`: 70 attribute, 2 object events). + Harness failures: 0. +- Report: [`aveva-history-801/aveva-gate-sdk2-882.json.gz`](aveva-history-801/aveva-gate-sdk2-882.json.gz), sha256 of the + JSON `449e689c…9b27` (label `sdk2-882-008f0b36`: the #882 working tree on top of `008f0b36`). + ## Files - [`aveva-history-801/aveva-gate-sdk2-d46044f5.json.gz`](aveva-history-801/aveva-gate-sdk2-d46044f5.json.gz), sha256 of the JSON `00b5ecb6…fc27`. diff --git a/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-882.json.gz b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-882.json.gz new file mode 100644 index 000000000..fcd2f221a Binary files /dev/null and b/docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-882.json.gz differ diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index d538adcd0..ed0992431 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -135,7 +135,11 @@ public interface IHistorianDataSource : IDisposable /// This surface declares and /// as required members — a server-side historian owns the full read surface. /// - /// The source name to filter events, or null to return events from all sources. + /// + /// The source name to filter events, or null to return events from all sources. The Gateway backend also returns + /// the events of the source's attributes, reported as <source>.<attribute> (AVEVA's attribute + /// alarms, Gitea #882); each event keeps its own . + /// /// The start of the time range in UTC. /// The end of the time range in UTC. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs index 666ca7f40..ebafc7087 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/GatewayHistorianDataSource.cs @@ -772,7 +772,7 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis // rows would make the server crawl it one resolution step per page). if (!truncated) { - if (sourceName is not null && !string.Equals(wireEvent.SourceName, sourceName, StringComparison.Ordinal)) + if (!EventSourceMatch.Matches(sourceName, wireEvent.SourceName)) continue; truncated = true; } @@ -787,8 +787,10 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis var mapped = EventMapper.ToHistoricalEvent(wireEvent); // Defensive client-side source filter: the gateway's SQL ReadEvents source filter - // may not be present, so drop any event whose source does not match the request. - if (sourceName is not null && !string.Equals(mapped.SourceName, sourceName, StringComparison.Ordinal)) + // may not be present, so drop any event whose source does not match the request. The request's own + // events and its attributes' (., the name the gateway reports an AVEVA attribute + // alarm under) match; a sibling that merely shares the prefix does not (#882, EventSourceMatch). + if (!EventSourceMatch.Matches(sourceName, mapped.SourceName)) { continue; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs index f356d52db..386c6079f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/IHistorianGatewayClient.cs @@ -58,7 +58,10 @@ public interface IHistorianGatewayClient : IAsyncDisposable CancellationToken ct); /// Streams historian events over a window, optionally filtered to a single source name. - /// Optional source-name filter; null returns events from all sources. + /// + /// Optional source-name filter, sent as Source_Object = sourceName; null returns events from all + /// sources. The gateway reports an attribute alarm of that object as <sourceName>.<attribute>. + /// /// Inclusive start of the time window (UTC). /// Exclusive end of the time window (UTC). /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventSourceMatch.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventSourceMatch.cs new file mode 100644 index 000000000..b4f74d072 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventSourceMatch.cs @@ -0,0 +1,38 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; + +/// +/// Which gateway events belong to a requested event source (Gitea #882): the source's own events, and the events of +/// its attributes. +/// +/// +/// +/// The gateway filters a per-source ReadEvents on AVEVA's Source_Object, and reports each row's +/// SourceName as Source_ProcessVariable (else Source_Object). An object-level event +/// (System.Stop / System.Start) therefore comes back named after the object, DevWinPlatform, +/// while an alarm on one of the object's attributes comes back named <Object>.<Attribute>, +/// DevWinPlatform.CPULoadAvg, although the gateway returned it for Source_Object = DevWinPlatform. +/// Both are the object's events; live, an attribute alarm's condition fires on the same folder as the object's own +/// events. +/// +/// +/// The match is on the . boundary, ordinal: DevWinPlatform matches DevWinPlatform and +/// DevWinPlatform.CPULoadAvg, never DevWinPlatform2 or DevWinPlatform2.CPULoadAvg. +/// +/// +internal static class EventSourceMatch +{ + /// Whether an event reported with belongs to . + /// The requested source; null requests every source. + /// The event's reported source name. + /// true when the event is the source's own or one of its attributes'. + public static bool Matches(string? requested, string? eventSource) + { + if (requested is null) return true; + if (eventSource is null) return false; + return eventSource.Length == requested.Length + ? string.Equals(eventSource, requested, StringComparison.Ordinal) + : eventSource.Length > requested.Length + 1 + && eventSource[requested.Length] == '.' + && eventSource.StartsWith(requested, StringComparison.Ordinal); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAttributeEventSourceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAttributeEventSourceTests.cs new file mode 100644 index 000000000..3f458da8a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAttributeEventSourceTests.cs @@ -0,0 +1,117 @@ +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests; + +/// +/// Gitea #882: the gateway answers a per-source read (Source_Object = DevWinPlatform) with the object's own +/// events, reported as DevWinPlatform, AND its attribute alarms, reported as DevWinPlatform.CPULoadAvg +/// (Source_ProcessVariable). Both belong to the source; a sibling sharing only the text prefix does not. +/// +public sealed class GatewayAttributeEventSourceTests +{ + private static readonly DateTime T0 = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc); + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + [Theory] + [InlineData("DevWinPlatform", "DevWinPlatform", true)] + [InlineData("DevWinPlatform", "DevWinPlatform.CPULoadAvg", true)] + [InlineData("DevWinPlatform", "DevWinPlatform.CPULoadAvg.HiAlarm", true)] + [InlineData("DevWinPlatform", "DevWinPlatform2", false)] + [InlineData("DevWinPlatform", "DevWinPlatform2.CPULoadAvg", false)] + [InlineData("DevWinPlatform", "DevWinPlatform_CPULoadAvg", false)] + [InlineData("DevWinPlatform", "DevWinPlatform.", false)] + [InlineData("DevWinPlatform", "DevWin", false)] + [InlineData("DevWinPlatform", "devwinplatform.CPULoadAvg", false)] + [InlineData("DevWinPlatform", "Other.DevWinPlatform", false)] + [InlineData("DevWinPlatform", null, false)] + [InlineData("DevWinPlatform.CPULoadAvg", "DevWinPlatform", false)] + [InlineData(null, "anything", true)] + public void A_source_matches_itself_and_its_attributes_on_the_dot_boundary(string? requested, string? source, bool matches) => + EventSourceMatch.Matches(requested, source).ShouldBe(matches); + + [Fact] + public async Task An_object_read_serves_its_attribute_events_and_no_prefix_sibling() + { + // The fake streams every row whatever the filter, so the sibling rows really reach the client-side filter. + var fake = new FakeHistorianGatewayClient + { + Events = + [ + Event("E0", "DevWinPlatform", 0, "System.Stop"), + Event("E1", "DevWinPlatform.CPULoadAvg", 1, "Alarm.Set"), + Event("E2", "DevWinPlatform2", 2, "System.Stop"), + Event("E3", "DevWinPlatform2.CPULoadAvg", 3, "Alarm.Set"), + Event("E4", "DevWinPlatform.CPULoadAvg", 4, "Alarm.Clear"), + Event("E5", "DevWinPlatformX.CPULoadAvg", 5, "Alarm.Set"), + Event("E6", "DevWinPlatform", 6, "System.Start"), + ], + }; + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + + var r = await ds.ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 0, Ct); + + fake.LastReadEventsSourceName.ShouldBe("DevWinPlatform", "one request, filtered on the object"); + fake.ReadEventsCallCount.ShouldBe(1); + fake.EventsYieldedCount.ShouldBe(7, "the sibling rows reached the filter, so their absence is the filter's doing"); + r.Events.Select(e => (e.EventId, e.SourceName)).ShouldBe(new (string, string?)[] + { + ("E0", "DevWinPlatform"), + ("E1", "DevWinPlatform.CPULoadAvg"), + ("E4", "DevWinPlatform.CPULoadAvg"), + ("E6", "DevWinPlatform"), + }); + r.ContinuationPoint.ShouldBeNull(); + } + + [Fact] + public async Task An_attribute_event_past_the_cap_signals_more_and_a_prefix_sibling_does_not() + { + // Past a full page only a MATCHING row is the "more events exist" signal: an attribute event is one, a + // DevWinPlatform2 row is not. + var withAttribute = new FakeHistorianGatewayClient + { + Events = [Event("E0", "DevWinPlatform", 0), Event("E1", "DevWinPlatform.CPULoadAvg", 1)], + }; + var withSibling = new FakeHistorianGatewayClient + { + Events = [Event("E0", "DevWinPlatform", 0), Event("E1", "DevWinPlatform2.CPULoadAvg", 1)], + }; + + var more = await new GatewayHistorianDataSource(withAttribute, NullLogger.Instance) + .ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 1, Ct); + var complete = await new GatewayHistorianDataSource(withSibling, NullLogger.Instance) + .ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 1, Ct); + + more.Events.Select(e => e.EventId).ShouldBe(new[] { "E0" }); + more.ContinuationPoint.ShouldNotBeNull(); + complete.Events.Select(e => e.EventId).ShouldBe(new[] { "E0" }); + complete.ContinuationPoint.ShouldBeNull(); + } + + [Fact] + public async Task An_attribute_named_read_still_matches_exactly() + { + // No attribute-level notifier is exposed; a read named after an attribute keeps the exact match (the real + // gateway returns nothing for it: its Source_Object is the object). + var fake = new FakeHistorianGatewayClient + { + Events = [Event("E0", "DevWinPlatform", 0), Event("E1", "DevWinPlatform.CPULoadAvg", 1)], + }; + var ds = new GatewayHistorianDataSource(fake, NullLogger.Instance); + + var r = await ds.ReadEventsAsync("DevWinPlatform.CPULoadAvg", T0, T0.AddHours(1), maxEvents: 0, Ct); + + r.Events.Select(e => e.EventId).ShouldBe(new[] { "E1" }); + } + + private static HistorianEvent Event(string id, string source, int second, string type = "Alarm.Set") + { + var at = Timestamp.FromDateTime(T0.AddSeconds(second)); + return new HistorianEvent { Id = id, SourceName = source, Type = type, EventTime = at, ReceivedTime = at }; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AttributeEventHistoryWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AttributeEventHistoryWireTests.cs new file mode 100644 index 000000000..f6d657c47 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AttributeEventHistoryWireTests.cs @@ -0,0 +1,185 @@ +using System.Runtime.CompilerServices; +using Google.Protobuf.WellKnownTypes; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.HistorianGateway.Contracts.Grpc; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Gitea #882 over a real SDK session, with the production Gateway backend over a deterministic AVEVA-shaped gateway: +/// an equipment folder's event history carries the object's own events AND its attribute alarms (which the gateway +/// reports as <Object>.<Attribute>), never a sibling that merely shares the name's prefix, and the +/// attribute events are governed by the folder's own HistoryRead verdict. +/// +/// +/// The rig's equipment folders EqA / EqB stand in for Galaxy objects: a folder's event source is its +/// NodeId identifier, so the fixture's AVEVA objects are named EqA / EqB, with a prefix sibling +/// EqA2. The fake gateway filters a per-source read more loosely than the real one (Source_Object +/// begins with, not equals, the source), so the sibling's rows reach OtOpcUa and their absence is OtOpcUa's filter at +/// work, not the fixture's. +/// +public sealed class AttributeEventHistoryWireTests +{ + private static readonly DateTime Epoch = new(2026, 11, 10, 11, 0, 0, DateTimeKind.Utc); + + private static readonly (string Id, string Source, string Object, string Type, int Second)[] Stored = + [ + ("a-stop", EqA, EqA, "System.Stop", 0), + ("a2-stop", "EqA2", "EqA2", "System.Stop", 1), + ("a-set", "EqA.CPULoadAvg", EqA, "Alarm.Set", 2), + ("a2-set", "EqA2.CPULoadAvg", "EqA2", "Alarm.Set", 3), + ("b-set", "EqB.Temp", EqB, "Alarm.Set", 4), + ("a-clear", "EqA.CPULoadAvg", EqA, "Alarm.Clear", 5), + ("a-start", EqA, EqA, "System.Start", 6), + ]; + + private static readonly string[] EqAEvents = ["a-stop", "a-set", "a-clear", "a-start"]; + + [Fact] + public async Task A_folder_serves_its_objects_attribute_events_with_their_own_source_and_no_prefix_sibling() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("AttrEventHistory", encrypted: false, ct); + var gateway = new AvevaShapedGateway(); + rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); + rig.MaterialiseNative(); + rig.Enforce(false, [Key()]); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var all = await ReadAsync(session, EqA, page: 0, ct); + + all.Status.ShouldBe(StatusCodes.Good); + all.Rows.Select(r => r.Id).ShouldBe(EqAEvents); + all.Rows.Select(r => r.Source).ShouldBe(new[] { EqA, "EqA.CPULoadAvg", "EqA.CPULoadAvg", EqA }, + "each event keeps the source the historian reported, the attribute's for an attribute alarm"); + all.Rows.ShouldAllBe(r => r.SourceNodeIsNull, "no materialised condition is named by an AVEVA row"); + gateway.Requested.ShouldAllBe(s => s == EqA, "the object's events and its attributes' come from one request on the object"); + gateway.Yielded.ShouldContain("a2-set", "the sibling's rows reached OtOpcUa, so their absence is its filter's doing"); + + // Paging one event a page walks the same four, attribute events included, and nothing else. + var paged = await ReadAsync(session, EqA, page: 1, ct); + paged.Rows.Select(r => r.Id).ShouldBe(EqAEvents); + paged.Pages.ShouldBeGreaterThanOrEqualTo(4); + } + + [Fact] + public async Task With_the_acl_on_a_denied_folder_returns_none_of_its_attribute_events_and_never_reaches_the_backend() + { + var ct = TestContext.Current.CancellationToken; + await using var rig = await StartAsync("AttrEventHistoryAcl", encrypted: false, ct); + var gateway = new AvevaShapedGateway(); + rig.Nm.HistorianDataSource = new GatewayHistorianDataSource(gateway, NullLogger.Instance); + rig.MaterialiseNative(); + // Anonymous is granted EqB only: EqA (whose object has attribute alarms) is denied. + rig.Enforce(true, [Key()], EquipmentGrant(EqB, NodePermissions.ReadOnly)); + + using var session = await rig.ConnectAsync(anonymous: true, ct); + var denied = await ReadAsync(session, EqA, page: 0, ct); + + denied.Status.ShouldBe(StatusCodes.BadUserAccessDenied); + denied.HistoryDataIsNull.ShouldBeTrue("a denied folder discloses none of its events"); + denied.Rows.ShouldBeEmpty(); + gateway.Requested.ShouldBeEmpty("the folder verdict is decided before any backend read (fail closed)"); + + // Positive control: the same session, on the folder it IS granted, gets that object's attribute alarm. + var granted = await ReadAsync(session, EqB, page: 0, ct); + granted.Status.ShouldBe(StatusCodes.Good); + granted.Rows.Select(r => (r.Id, r.Source)).ShouldBe(new (string, string?)[] { ("b-set", "EqB.Temp") }); + gateway.Requested.ShouldBe(new[] { EqB }); + } + + private static async Task ReadAsync(ISession session, string folder, uint page, CancellationToken ct) + { + var node = new NodeId(folder, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.UnsNamespaceUri)); + var filter = new EventFilter(); + foreach (var leaf in new[] { "EventId", "SourceName", "SourceNode" }) + filter.SelectClauses += new SimpleAttributeOperand(ObjectTypeIds.BaseEventType, new QualifiedName(leaf)) { AttributeId = Attributes.Value }; + var details = new ReadEventDetails + { + StartTime = Epoch.AddMinutes(-1), EndTime = Epoch.AddMinutes(1), NumValuesPerNode = page, Filter = filter, + }; + var rows = new List(); + ByteString point = default; + var pages = 0; + StatusCode first = StatusCodes.Good; + var historyDataIsNull = true; + do + { + var response = await session.HistoryReadAsync(null, new ExtensionObject(details), TimestampsToReturn.Both, false, + new HistoryReadValueId[] { new() { NodeId = node, ContinuationPoint = point } }, ct); + var result = response.Results.ToList().ShouldHaveSingleItem(); + if (pages++ == 0) { first = result.StatusCode; historyDataIsNull = result.HistoryData.IsNull; } + if (StatusCode.IsBad(result.StatusCode)) break; + 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 Row(System.Text.Encoding.UTF8.GetString(f[0].GetByteString().Span), f[1].GetString(), f[2].IsNull)); + } + point = result.ContinuationPoint; + } while (!point.IsEmpty && pages < 50); + return new EventRead(first, historyDataIsNull, rows, pages); + } + + private sealed record Row(string Id, string? Source, bool SourceNodeIsNull); + + private sealed record EventRead(StatusCode Status, bool HistoryDataIsNull, List Rows, int Pages); + + /// + /// A deterministic gateway answering ReadEvents with AVEVA-shaped rows (SourceName = + /// Source_ProcessVariable, a source_object property), filtered loosely on source_object. + /// Every other member is unused here and throws. + /// + private sealed class AvevaShapedGateway : IHistorianGatewayClient + { + private readonly List _requested = []; + private readonly List _yielded = []; + + public IReadOnlyList Requested { get { lock (_requested) return _requested.ToList(); } } + + public IReadOnlyList Yielded { get { lock (_yielded) return _yielded.ToList(); } } + + public async IAsyncEnumerable ReadEventsAsync( + string? sourceName, DateTime startUtc, DateTime endUtc, int maxEvents, [EnumeratorCancellation] CancellationToken ct) + { + lock (_requested) _requested.Add(sourceName); + await Task.Yield(); + foreach (var s in Stored.OrderBy(s => s.Second)) + { + var at = Epoch.AddSeconds(s.Second); + if (at < startUtc || at > endUtc) continue; // the gateway's end is inclusive + if (sourceName is not null && !s.Object.StartsWith(sourceName, StringComparison.Ordinal)) continue; + lock (_yielded) _yielded.Add(s.Id); + var ts = Timestamp.FromDateTime(at); + var e = new HistorianEvent { Id = s.Id, SourceName = s.Source, Type = s.Type, EventTime = ts, ReceivedTime = ts }; + e.Properties["source_object"] = s.Object; + e.Properties["source_processvariable"] = s.Source; + yield return e; + } + } + + public Task TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException(); + public IAsyncEnumerable ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) => + throw new NotSupportedException(); + public IAsyncEnumerable ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc, + RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException(); + public Task> ReadAtTimeAsync(string tag, IReadOnlyList timestampsUtc, CancellationToken ct) => + throw new NotSupportedException(); + public Task WriteLiveValuesAsync(string tag, IReadOnlyList values, CancellationToken ct) => + throw new NotSupportedException(); + public Task SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException(); + public Task EnsureTagsAsync(IReadOnlyList definitions, CancellationToken ct) => + throw new NotSupportedException(); + public Task ProbeAsync(CancellationToken ct) => Task.FromResult(true); + public Task GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus()); + public ValueTask DisposeAsync() => ValueTask.CompletedTask; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs index 1e82497e7..27c9ad8d4 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs @@ -46,10 +46,11 @@ public sealed class AvevaGatewayHistoryLiveGateTests // 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. The second is an attribute alarm the - // gateway reports with SourceName "DevWinPlatform.CPULoadAvg" (its Source_ProcessVariable; Source_Object is - // DevWinPlatform), but a per-source ReadEvents for that name returns nothing — on SDK 1.5.378 and 2.0.1 alike. Recorded - // (wire must equal the backend), not required to be non-empty. + // 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"; private const string MissingTag = "OtOpcUa.Gate801.DoesNotExist"; @@ -278,7 +279,20 @@ public sealed class AvevaGatewayHistoryLiveGateTests 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 (.) — + // exactly the unfiltered read's rows named the object or .*, 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());