From da948e5fb5547827a72f0d6495704e9a240af207 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 10:34:28 -0500 Subject: [PATCH] fix(history): a folder's event history includes its object's attribute alarms (#882) HistorianGateway answers a per-source ReadEvents (Source_Object = DevWinPlatform) with the object's own events (SourceName DevWinPlatform) AND its attribute alarms, which it reports as . (Source_ProcessVariable, e.g. DevWinPlatform.CPULoadAvg). The Gateway backend's defensive client-side source filter compared SourceName for equality, so it dropped the attribute alarms: 70 of the 72 DevWinPlatform events on vd03. The filter (and the past-the-cap "more events" signal) now uses EventSourceMatch: the source itself, or . on the '.' boundary, ordinal. DevWinPlatform never matches DevWinPlatform2 or DevWinPlatform2.X. This needs no wire change and still makes one request. Each event keeps its own SourceName. SourceNode stays the condition its stored alarm id names, which is null for AVEVA rows. The attribute events ride the notifier's own read, so the per-folder HistoryRead verdict (#826) governs them: a denied folder answers BadUserAccessDenied before any backend call. The SqlServer and QuestDB backends still match exactly; they hold only OtOpcUa's own events. Tests: - GatewayAttributeEventSourceTests (16): the boundary theory, attribute-in/sibling-out through the data source, the drain signal, and an exact attribute-named read. - AttributeEventHistoryWireTests (2, over a real SDK session with the production Gateway backend on an AVEVA-shaped deterministic gateway): object + attribute events with their own SourceName, a prefix sibling excluded, paging, and the ACL-on deny with no backend call plus a granted positive control. - Mutation-checked: exact match fails both wire tests; dropping the '.' boundary fails the wire sibling check and 6 unit tests. - Live AVEVA harness re-run against vd03 HistorianGateway ea9f21c (read-only, no WinRM): folder DevWinPlatform 2 -> 72 events (whole, 72 1-event pages, reverse); the new #882 check passes; 221 of 225 shared cases are identical to the #801 SDK2 baseline, and the 4 that differ are exactly the DevWinPlatform event cases. Recorded in aveva-history-801.md. Follow-up #886: production folders name RawPaths/EquipmentIds, not Galaxy objects, plus the AVEVA severity/condition property mapping. Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- docs/Historian.md | 20 +- .../2026-11-10-opcua2/aveva-history-801.md | 24 ++- .../aveva-gate-sdk2-882.json.gz | Bin 0 -> 33506 bytes .../Historian/IHistorianDataSource.cs | 6 +- .../GatewayHistorianDataSource.cs | 8 +- .../IHistorianGatewayClient.cs | 5 +- .../Mapping/EventSourceMatch.cs | 38 ++++ .../GatewayAttributeEventSourceTests.cs | 117 +++++++++++ .../AttributeEventHistoryWireTests.cs | 185 ++++++++++++++++++ .../AvevaGatewayHistoryLiveGateTests.cs | 22 ++- 10 files changed, 413 insertions(+), 12 deletions(-) create mode 100644 docs/plans/evidence/2026-11-10-opcua2/aveva-history-801/aveva-gate-sdk2-882.json.gz create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway/Mapping/EventSourceMatch.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Tests/GatewayAttributeEventSourceTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AttributeEventHistoryWireTests.cs 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 0000000000000000000000000000000000000000..fcd2f221a412e3e3ca67b5416180977885a98968 GIT binary patch literal 33506 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 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());