d90f35b6be
Review 01 U3. EnsureFolderIsEventNotifier granted the EventNotifiers .HistoryRead bit (and registered the event-history source) only when a real HistorianDataSource was wired at promotion time, and EnsureVariable composed the AccessLevels.HistoryWrite bit from whether a HistorianDataUpdater existed at materialise time — so an assignment after the first apply left existing folders without event history and existing nodes refused by the SDK's HistoryUpdate admission until a full rebuild, and a source reset to Null never took the bit back. The two setters now re-evaluate: HistorianDataSource walks every promoted folder (bit + source registration, both directions), HistorianDataUpdater recomposes the AccessLevel of every historized variable (Writable read back off the node's own CurrentWrite bit). The Host's boot ordering is no longer load-bearing. Tests: NodeManagerCapabilityRefreshTests (late source lights the bit on an already-promoted folder and a reset takes it back, live-event subscription untouched; late updater grants HistoryWrite to historized nodes only and a reset takes it back, authored Writable preserved). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
126 lines
6.7 KiB
C#
126 lines
6.7 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
|
|
|
|
/// <summary>
|
|
/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a
|
|
/// <see cref="OtOpcUaNodeManager.HistorianDataSource"/> or <see cref="OtOpcUaNodeManager.HistorianDataUpdater"/>
|
|
/// assigned after the first apply left existing folders without event history (and existing nodes
|
|
/// without HistoryUpdate) until a full rebuild, and a source reset to Null never took the bit back.
|
|
/// Both setters now re-evaluate the bits on every existing node.
|
|
/// </summary>
|
|
public sealed class NodeManagerCapabilityRefreshTests : IDisposable
|
|
{
|
|
private static CancellationToken Ct => TestContext.Current.CancellationToken;
|
|
|
|
private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-caprefresh-{Guid.NewGuid():N}");
|
|
|
|
[Fact]
|
|
public async Task A_late_historian_source_grants_event_history_to_already_promoted_folders_and_a_reset_takes_it_back()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns);
|
|
nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, AddressSpaceRealm.Uns);
|
|
var folder = nm.TryGetFolder("eq-1")!;
|
|
|
|
// Promoted while the source is Null: live events only (non-vacuity for the refresh).
|
|
(folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0);
|
|
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0);
|
|
nm.IsEventHistorySource("eq-1").ShouldBeFalse();
|
|
|
|
nm.HistorianDataSource = new StubSource();
|
|
|
|
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldNotBe((byte)0, "a late source must light the bit on the existing folder");
|
|
nm.IsEventHistorySource("eq-1").ShouldBeTrue();
|
|
|
|
nm.HistorianDataSource = null!; // the setter maps null to the Null source
|
|
|
|
(folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0, "a reset source must take the bit back");
|
|
(folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0, "live-event subscription is untouched");
|
|
nm.IsEventHistorySource("eq-1").ShouldBeFalse();
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_late_updater_grants_history_write_to_historized_nodes_only_and_a_reset_takes_it_back()
|
|
{
|
|
var (host, server) = await BootAsync();
|
|
try
|
|
{
|
|
var nm = server.NodeManager!;
|
|
nm.EnsureVariable("Plant/dev1/hist", null, "hist", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: "Hist.A");
|
|
nm.EnsureVariable("Plant/dev1/plain", null, "plain", "Float", writable: false, realm: AddressSpaceRealm.Raw);
|
|
var hist = nm.TryGetVariable("Plant/dev1/hist", AddressSpaceRealm.Raw)!;
|
|
var plain = nm.TryGetVariable("Plant/dev1/plain", AddressSpaceRealm.Raw)!;
|
|
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "no updater at materialise time");
|
|
|
|
nm.HistorianDataUpdater = new StubUpdater();
|
|
|
|
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
|
|
(hist.UserAccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0);
|
|
(hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0, "the authored Writable survives the refresh");
|
|
(hist.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0);
|
|
(plain.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "a non-historized node never gets HistoryWrite");
|
|
|
|
nm.HistorianDataUpdater = null;
|
|
|
|
(hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0);
|
|
(hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0);
|
|
}
|
|
finally { await host.DisposeAsync(); }
|
|
}
|
|
|
|
private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() =>
|
|
await OpcUaHostStart.StartAsync(
|
|
port => new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = "OtOpcUa.CapRefreshTest",
|
|
ApplicationUri = $"urn:OtOpcUa.CapRefreshTest:{Guid.NewGuid():N}",
|
|
OpcUaPort = port,
|
|
PublicHostname = "localhost",
|
|
PkiStoreRoot = _pkiRoot,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance),
|
|
() => new OtOpcUaSdkServer(), Ct);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true);
|
|
}
|
|
|
|
private sealed class StubSource : IHistorianDataSource
|
|
{
|
|
public Task<HistorianRead> ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct);
|
|
public Task<HistorianRead> ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct);
|
|
public Task<HistorianRead> ReadAtTimeAsync(string r, IReadOnlyList<DateTime> t, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct);
|
|
public Task<HistoricalEventsResult> ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) =>
|
|
NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct);
|
|
public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot();
|
|
public void Dispose() { }
|
|
}
|
|
|
|
private sealed class StubUpdater : IHistorianDataUpdater
|
|
{
|
|
public Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
public Task<IReadOnlyList<uint>> DeleteAtTimeAsync(string tag, IReadOnlyList<DateTime> timesUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
|
throw new NotSupportedException();
|
|
}
|
|
}
|