fix(history): a folder's event history includes its object's attribute alarms (#882)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
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 <Object>.<Attribute> (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 <source>.<anything> 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
This commit is contained in:
+19
-1
@@ -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):
|
||||
|
||||
|
||||
@@ -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, `<Object>.<Attribute>`,
|
||||
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<x>` 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`.
|
||||
|
||||
Binary file not shown.
@@ -135,7 +135,11 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// This surface declares <see cref="ReadAtTimeAsync(string, IReadOnlyList{DateTime}, CancellationToken)"/> and <see cref="ReadEventsAsync(string?, DateTime, DateTime, int, CancellationToken)"/>
|
||||
/// as required members — a server-side historian owns the full read surface.
|
||||
/// </remarks>
|
||||
/// <param name="sourceName">The source name to filter events, or null to return events from all sources.</param>
|
||||
/// <param name="sourceName">
|
||||
/// 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 <c><source>.<attribute></c> (AVEVA's attribute
|
||||
/// alarms, Gitea #882); each event keeps its own <see cref="HistoricalEvent.SourceName"/>.
|
||||
/// </param>
|
||||
/// <param name="startUtc">The start of the time range in UTC.</param>
|
||||
/// <param name="endUtc">The end of the time range in UTC.</param>
|
||||
/// <param name="maxEvents">
|
||||
|
||||
+5
-3
@@ -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' (<source>.<attribute>, 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;
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@ public interface IHistorianGatewayClient : IAsyncDisposable
|
||||
CancellationToken ct);
|
||||
|
||||
/// <summary>Streams historian events over a window, optionally filtered to a single source name.</summary>
|
||||
/// <param name="sourceName">Optional source-name filter; <c>null</c> returns events from all sources.</param>
|
||||
/// <param name="sourceName">
|
||||
/// Optional source-name filter, sent as <c>Source_Object = sourceName</c>; <c>null</c> returns events from all
|
||||
/// sources. The gateway reports an attribute alarm of that object as <c><sourceName>.<attribute></c>.
|
||||
/// </param>
|
||||
/// <param name="startUtc">Inclusive start of the time window (UTC).</param>
|
||||
/// <param name="endUtc">Exclusive end of the time window (UTC).</param>
|
||||
/// <param name="maxEvents">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Gateway.Mapping;
|
||||
|
||||
/// <summary>
|
||||
/// Which gateway events belong to a requested event source (Gitea #882): the source's own events, and the events of
|
||||
/// its attributes.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The gateway filters a per-source <c>ReadEvents</c> on AVEVA's <c>Source_Object</c>, and reports each row's
|
||||
/// <c>SourceName</c> as <c>Source_ProcessVariable</c> (else <c>Source_Object</c>). An object-level event
|
||||
/// (<c>System.Stop</c> / <c>System.Start</c>) therefore comes back named after the object, <c>DevWinPlatform</c>,
|
||||
/// while an alarm on one of the object's attributes comes back named <c><Object>.<Attribute></c>,
|
||||
/// <c>DevWinPlatform.CPULoadAvg</c>, although the gateway returned it for <c>Source_Object = DevWinPlatform</c>.
|
||||
/// Both are the object's events; live, an attribute alarm's condition fires on the same folder as the object's own
|
||||
/// events.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The match is on the <c>.</c> boundary, ordinal: <c>DevWinPlatform</c> matches <c>DevWinPlatform</c> and
|
||||
/// <c>DevWinPlatform.CPULoadAvg</c>, never <c>DevWinPlatform2</c> or <c>DevWinPlatform2.CPULoadAvg</c>.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal static class EventSourceMatch
|
||||
{
|
||||
/// <summary>Whether an event reported with <paramref name="eventSource"/> belongs to <paramref name="requested"/>.</summary>
|
||||
/// <param name="requested">The requested source; <c>null</c> requests every source.</param>
|
||||
/// <param name="eventSource">The event's reported source name.</param>
|
||||
/// <returns><c>true</c> when the event is the source's own or one of its attributes'.</returns>
|
||||
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);
|
||||
}
|
||||
}
|
||||
+117
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Gitea #882: the gateway answers a per-source read (<c>Source_Object = DevWinPlatform</c>) with the object's own
|
||||
/// events, reported as <c>DevWinPlatform</c>, AND its attribute alarms, reported as <c>DevWinPlatform.CPULoadAvg</c>
|
||||
/// (<c>Source_ProcessVariable</c>). Both belong to the source; a sibling sharing only the text prefix does not.
|
||||
/// </summary>
|
||||
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<GatewayHistorianDataSource>.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<GatewayHistorianDataSource>.Instance)
|
||||
.ReadEventsAsync("DevWinPlatform", T0, T0.AddHours(1), maxEvents: 1, Ct);
|
||||
var complete = await new GatewayHistorianDataSource(withSibling, NullLogger<GatewayHistorianDataSource>.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<GatewayHistorianDataSource>.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 };
|
||||
}
|
||||
}
|
||||
+185
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// 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 <c><Object>.<Attribute></c>), never a sibling that merely shares the name's prefix, and the
|
||||
/// attribute events are governed by the folder's own HistoryRead verdict.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The rig's equipment folders <c>EqA</c> / <c>EqB</c> stand in for Galaxy objects: a folder's event source is its
|
||||
/// NodeId identifier, so the fixture's AVEVA objects are named <c>EqA</c> / <c>EqB</c>, with a prefix sibling
|
||||
/// <c>EqA2</c>. The fake gateway filters a per-source read more loosely than the real one (<c>Source_Object</c>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
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<GatewayHistorianDataSource>.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<GatewayHistorianDataSource>.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<EventRead> 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<Row>();
|
||||
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<Row> Rows, int Pages);
|
||||
|
||||
/// <summary>
|
||||
/// A deterministic gateway answering ReadEvents with AVEVA-shaped rows (<c>SourceName</c> =
|
||||
/// <c>Source_ProcessVariable</c>, a <c>source_object</c> property), filtered loosely on <c>source_object</c>.
|
||||
/// Every other member is unused here and throws.
|
||||
/// </summary>
|
||||
private sealed class AvevaShapedGateway : IHistorianGatewayClient
|
||||
{
|
||||
private readonly List<string?> _requested = [];
|
||||
private readonly List<string> _yielded = [];
|
||||
|
||||
public IReadOnlyList<string?> Requested { get { lock (_requested) return _requested.ToList(); } }
|
||||
|
||||
public IReadOnlyList<string> Yielded { get { lock (_yielded) return _yielded.ToList(); } }
|
||||
|
||||
public async IAsyncEnumerable<HistorianEvent> 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<bool> TagExistsAsync(string tag, CancellationToken ct) => throw new NotSupportedException();
|
||||
public IAsyncEnumerable<HistorianSample> ReadRawAsync(string tag, DateTime startUtc, DateTime endUtc, int maxValues, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public IAsyncEnumerable<HistorianAggregateSample> ReadAggregateAsync(string tag, DateTime startUtc, DateTime endUtc,
|
||||
RetrievalMode mode, TimeSpan interval, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<IReadOnlyList<HistorianSample>> ReadAtTimeAsync(string tag, IReadOnlyList<DateTime> timestampsUtc, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<WriteAck> WriteLiveValuesAsync(string tag, IReadOnlyList<HistorianLiveValue> values, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<WriteAck> SendEventAsync(HistorianEvent evt, CancellationToken ct) => throw new NotSupportedException();
|
||||
public Task<TagOperationResults> EnsureTagsAsync(IReadOnlyList<HistorianTagDefinition> definitions, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<bool> ProbeAsync(CancellationToken ct) => Task.FromResult(true);
|
||||
public Task<ConnectionStatus> GetConnectionStatusAsync(CancellationToken ct) => Task.FromResult(new ConnectionStatus());
|
||||
public ValueTask DisposeAsync() => ValueTask.CompletedTask;
|
||||
}
|
||||
}
|
||||
+18
-4
@@ -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 (<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());
|
||||
|
||||
Reference in New Issue
Block a user