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();
+ }
+}