diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 8f03cd75..4edeac81 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -368,9 +368,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { _historianDataSource = value ?? NullHistorianDataSource.Instance; SyncAdvertisedAggregates(); + RefreshEventHistoryCapability(); } } + /// + /// Review 01 U3 — re-evaluates the bit (and the event-history + /// source registration resolves against) on every folder already promoted + /// to an event notifier, so a assigned AFTER the first apply advertises + /// event history without a full rebuild — and a source reset to the Null default takes the bit back + /// instead of leaving folders answering BadHistoryOperationUnsupported. Folders promoted later go + /// through , which reads the same source. + /// + private void RefreshEventHistoryCapability() + { + var wired = _historianDataSource is not NullHistorianDataSource; + lock (Lock) + { + foreach (var (nodeId, folder) in _notifierFolders) + { + var has = (folder.EventNotifier & EventNotifiers.HistoryRead) != 0; + if (has == wired) continue; + var key = MapKey(nodeId); + if (wired) + { + folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); + _eventNotifierSources[key] = nodeId.Identifier?.ToString() ?? string.Empty; + } + else + { + folder.EventNotifier = (byte)(folder.EventNotifier & ~EventNotifiers.HistoryRead); + _eventNotifierSources.TryRemove(key, out _); + } + + folder.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// + /// Review 01 U3 — re-evaluates the bit on every historized + /// variable, so a assigned (or cleared) after nodes exist changes what + /// the SDK base admits to HistoryUpdate without a full rebuild. Writable is read back off the node's + /// own CurrentWrite bit — the authored value was given. + /// + private void RefreshHistoryWriteCapability() + { + var historyWritable = _historianDataUpdater is not null; + lock (Lock) + { + foreach (var (key, variable) in _variables) + { + if (!_historizedTagnames.ContainsKey(key)) continue; + var writable = (variable.AccessLevel & AccessLevels.CurrentWrite) != 0; + var access = ComposeAccessLevel(writable, historized: true, historyWritable); + if (variable.AccessLevel == access && variable.UserAccessLevel == access) continue; + variable.AccessLevel = access; + variable.UserAccessLevel = access; + variable.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// Test observability: whether is registered as an event-history + /// source (what accepts). + internal bool IsEventHistorySource(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _eventNotifierSources.ContainsKey(MapKey(realm, folderNodeId)); + /// /// Trim HistoryServerCapabilities/AggregateFunctions (i=11201) to the current source's /// (null ⇒ all, ). @@ -408,20 +472,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// delete of historized samples). null (the default) means the selected historian backend /// cannot modify history: every HistoryUpdate is reported BadHistoryOperationUnsupported. /// - /// ⚠️ Assign it before any node materialises. Whether an updater is present decides the - /// HistoryWrite AccessLevel bit a historized node is created with - /// (), and the SDK base admits a node to HistoryUpdate only when - /// that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Setting the updater after - /// nodes exist does NOT retro-update their AccessLevel. The Host assigns it in - /// OtOpcUaServerHostedService.StartAsync before installing the address-space sink, which - /// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as - /// . + /// Whether an updater is present decides the HistoryWrite AccessLevel bit a historized node + /// carries (), and the SDK base admits a node to HistoryUpdate only + /// when that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Assigning the updater + /// after nodes exist re-evaluates the bit on every historized node (review 01 U3 — + /// ), so the Host's ordering (assigned in + /// OtOpcUaServerHostedService.StartAsync before the sink is installed) is no longer load-bearing. + /// Volatile for the same start-thread / request-thread handoff as . /// /// public IHistorianDataUpdater? HistorianDataUpdater { get => _historianDataUpdater; - set => _historianDataUpdater = value; + set + { + _historianDataUpdater = value; + RefreshHistoryWriteCapability(); + } } /// @@ -2914,12 +2981,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// ), the folder ALSO gets the /// bit OR-ed in (keeping SubscribeToEvents) and registers /// its NodeId identifier as an event-history source so the override - /// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at - /// the moment of promotion: the Host wires the source at StartAsync — BEFORE any deployment - /// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder - /// promoted while the source is still Null advertises live-event subscription but NOT event history - /// until the next re-promotes it (acceptable, documented). The - /// HistoryRead bit + source registration happen inside the same first-time + /// accepts it. A folder promoted while the source is still Null advertises live-event subscription + /// only; a later assignment re-evaluates every promoted folder + /// (review 01 U3 — ), so the Host's boot ordering is no + /// longer load-bearing. The HistoryRead bit + source registration happen inside the same first-time /// block so the idempotency guard covers them too. /// private void EnsureFolderIsEventNotifier(FolderState folder) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs new file mode 100644 index 00000000..428f74a2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs @@ -0,0 +1,125 @@ +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; + +/// +/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a +/// or +/// 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. +/// +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.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class StubSource : IHistorianDataSource + { + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task 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> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + } +}