From b27b5b9cc12b515582d523cede2ecc9b2deb920c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 12 Nov 2026 10:31:29 -0500 Subject: [PATCH] fix(history): advertise HistoryServerCapabilities from the wired backend (#881) Every Boolean under Server/ServerCapabilities/HistoryServerCapabilities read false (the SDK default) even though data and event history were served, so a spec-following client skipped HistoryRead entirely. Each backend now declares what it serves through its own interface: - IHistorianDataSource.ReadCapabilities (Data / Events / ServerTimestamps): Gateway Data|Events; SqlServer + QuestDb add ServerTimestamps (they store ServerTimeUtc, the Gateway stamps read time); Null declares None. A reflection guard requires every production source to declare it itself. - IHistorianDataUpdater.Capabilities (required member): SqlServer and QuestDb declare Insert/Replace/Update/DeleteRaw/DeleteAtTime; the Gateway provider has no updater. The node manager writes the flags on every source/updater assignment (AdvertisedHistoryCapabilities), and the same declarations drive dispatch: an update the updater does not declare is BadHistoryOperationUnsupported without reaching it, the HistoryWrite AccessLevel bit follows a non-empty declaration, and event-history notifier promotion follows the Events flag. Event and annotation update flags stay false (nothing dispatches them). Tests: booted-server wire test per backend + Null + a SqlServer->Gateway rewire pairing on one session; dispatch gating unit test; the #801 live gate now asserts the Gateway flags (not run here). Claude-Session: https://claude.ai/code/session_01GT67ACmpMR7b9LGRPLijMi --- .../Historian/HistoryCapabilities.cs | 57 ++++++ .../Historian/IHistorianDataSource.cs | 13 ++ .../Historian/IHistorianDataUpdater.cs | 9 + .../Historian/NullHistorianDataSource.cs | 5 + .../GatewayHistorianDataSource.cs | 5 + .../QuestDbHistorianDataSource.cs | 5 + .../QuestDbHistorianDataUpdater.cs | 4 + .../SqlServerHistorianDataSource.cs | 6 + .../SqlServerHistorianDataUpdater.cs | 5 + .../AdvertisedHistoryCapabilities.cs | 107 +++++++++++ .../OtOpcUaNodeManager.cs | 83 +++++++-- ...torianDataSourceReverseEventsGuardTests.cs | 16 ++ .../AvevaGatewayHistoryLiveGateTests.cs | 11 ++ .../HistoryServerCapabilitiesWireTests.cs | 166 ++++++++++++++++++ .../HistoryUpdateOverTheWireTests.cs | 2 + .../History/HistoryUpdateTests.cs | 46 +++++ .../NodeManagerCapabilityRefreshTests.cs | 1 + 17 files changed, 526 insertions(+), 15 deletions(-) create mode 100644 src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryCapabilities.cs create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedHistoryCapabilities.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryServerCapabilitiesWireTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryCapabilities.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryCapabilities.cs new file mode 100644 index 000000000..99de5626e --- /dev/null +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/HistoryCapabilities.cs @@ -0,0 +1,57 @@ +namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +/// +/// What a historian backend serves for OPC UA HistoryRead — declared by every +/// () and advertised by the server under +/// Server/ServerCapabilities/HistoryServerCapabilities (lmxopcua#881). +/// +[Flags] +public enum HistorianReadCapabilities +{ + /// No history is served (the Null source). + None = 0, + + /// Data history (raw / modified / processed / at-time) — AccessHistoryDataCapability. + Data = 1, + + /// Event history on notifier folders — AccessHistoryEventsCapability. + Events = 2, + + /// + /// The backend stores each sample's server timestamp and returns it on a raw read, rather than stamping the read + /// time — ServerTimestampSupported (Part 11 §5.4.2). + /// + ServerTimestamps = 4, +} + +/// +/// Which HistoryUpdate operations a performs — declared by the updater itself +/// (). The server both advertises these under +/// HistoryServerCapabilities and refuses an undeclared operation with BadHistoryOperationUnsupported, +/// so the advertisement cannot drift from dispatch (lmxopcua#881). There are no event / annotation members: the +/// server dispatches no event or annotation updates to any backend. +/// +[Flags] +public enum HistoryUpdateCapabilities +{ + /// No update operation. + None = 0, + + /// UpdateDataDetails with PerformInsertReplace = Insert — InsertDataCapability. + InsertData = 1, + + /// UpdateDataDetails with PerformInsertReplace = Replace — ReplaceDataCapability. + ReplaceData = 2, + + /// UpdateDataDetails with PerformInsertReplace = Update — UpdateDataCapability. + UpdateData = 4, + + /// DeleteRawModifiedDetails with IsDeleteModified = false — DeleteRawCapability. + DeleteRaw = 8, + + /// DeleteAtTimeDetails — DeleteAtTimeCapability. + DeleteAtTime = 16, + + /// Every data update the server can dispatch. + AllData = InsertData | ReplaceData | UpdateData | DeleteRaw | DeleteAtTime, +} diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs index faea53708..d538adcd0 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataSource.cs @@ -246,6 +246,19 @@ public interface IHistorianDataSource : IDisposable /// IReadOnlySet SupportedAggregates => HistoryAggregateTypes.All; + /// + /// What this backend serves for HistoryRead — the authority for the AccessHistoryDataCapability, + /// AccessHistoryEventsCapability and ServerTimestampSupported flags the server advertises under + /// HistoryServerCapabilities, and () for whether notifier folders + /// carry the HistoryRead EventNotifier bit and accept HistoryRead-Events (lmxopcua#881). + /// + /// + /// The default ( | ) keeps a + /// test fake or out-of-tree source serving as before. Every PRODUCTION backend declares it itself (a reflection + /// guard pins that); the Null source declares . + /// + HistorianReadCapabilities ReadCapabilities => HistorianReadCapabilities.Data | HistorianReadCapabilities.Events; + /// /// Read modified-value history — OPC UA HistoryReadRaw with IsReadModified = true (Part 11): /// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs index 00fc15186..a138249bc 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/IHistorianDataUpdater.cs @@ -32,6 +32,15 @@ public enum HistoryUpdateMode /// public interface IHistorianDataUpdater { + /// + /// The update operations this backend performs — declared by the backend itself (no default, so it cannot be + /// forgotten). The server advertises exactly these under HistoryServerCapabilities + /// (Insert/Replace/UpdateData, DeleteRaw, DeleteAtTime) and answers an operation outside the set with + /// BadHistoryOperationUnsupported without calling the backend (lmxopcua#881). Read when the updater is + /// wired and on every update, so it must be cheap and stable. + /// + HistoryUpdateCapabilities Capabilities { get; } + /// Inserts, replaces or upserts samples for one tag. /// Historian tag name. /// Update mode. diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs index 0c4c84676..6be3aa167 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/Historian/NullHistorianDataSource.cs @@ -87,6 +87,11 @@ public sealed class NullHistorianDataSource : IHistorianDataSource // historian misled capability discovery. Raw / AtTime / Events reads still return the empty GoodNoData result. public IReadOnlySet SupportedAggregates => HistoryAggregateTypes.None; + /// + // None: no history is served, so HistoryServerCapabilities advertises no data / event access (lmxopcua#881) and no + // folder is promoted to an event-history notifier. + public HistorianReadCapabilities ReadCapabilities => HistorianReadCapabilities.None; + /// public HistorianHealthSnapshot GetHealthSnapshot() => new( TotalQueries: 0, 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 a34468f81..666ca7f40 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 @@ -721,6 +721,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis public IReadOnlySet SupportedAggregates => _enableNativeTimeWeighted ? ServedAggregates : RawEvaluatedAggregates; + /// + // Data + events, but NOT ServerTimestamps: AVEVA keeps no per-sample server time, so SampleMapper stamps the read + // time — advertising ServerTimestampSupported would promise a stored value we do not have (lmxopcua#881). + public HistorianReadCapabilities ReadCapabilities => HistorianReadCapabilities.Data | HistorianReadCapabilities.Events; + /// // A reverse (newest-first) read is refused with NotSupportedException — the gateway's // ReadEvents streams ascending only and rejects end < start. The server never calls this with diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs index eb640d0e3..18a247512 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataSource.cs @@ -438,6 +438,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource /// public IReadOnlySet SupportedAggregates => AggregateFamilies.Served; + /// + // Data + events; every sample row stores its server time and a raw read returns it (lmxopcua#881). + public HistorianReadCapabilities ReadCapabilities => + HistorianReadCapabilities.Data | HistorianReadCapabilities.Events | HistorianReadCapabilities.ServerTimestamps; + // [start, end) by event time (a reversed window is swapped), ordered by (ts, event_id) ascending. // maxEvents <= 0 or int.MaxValue => unbounded. Otherwise maxEvents + 1 rows are fetched; when the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataUpdater.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataUpdater.cs index 69c797bae..d699d040f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataUpdater.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.QuestDb/QuestDbHistorianDataUpdater.cs @@ -87,6 +87,10 @@ internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater public void Dispose() => sem.Release(); } + /// + // All five data updates; the deletes are soft (tombstone rows), which a reader cannot tell from a hard delete. + public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData; + /// public async Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs index 3375ddd30..c3d03d420 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataSource.cs @@ -363,6 +363,12 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource /// public IReadOnlySet SupportedAggregates => AggregateFamilies.Served; + /// + // Data + events; every sample row stores ServerTimeUtc (written by the recorder / HistoryUpdate) and a raw read + // returns it (lmxopcua#881). + public HistorianReadCapabilities ReadCapabilities => + HistorianReadCapabilities.Data | HistorianReadCapabilities.Events | HistorianReadCapabilities.ServerTimestamps; + // // [start, end) by EventTimeUtc (a reversed window is swapped), ordered by diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs index 8b10e5667..d49016b0e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Historian.SqlServer/SqlServerHistorianDataUpdater.cs @@ -67,6 +67,11 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater "HistoryUpdate needs the Part 11 modification trail (apply 002_sample_modified.sql and restart)."); } + /// + // All five data updates (MERGE / OUTPUT-trail deletes). On a trail-less v1 schema (AutoCreateSchema=false, logged at + // boot) every call still throws NotSupportedException at run time → BadHistoryOperationUnsupported. + public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData; + /// public async Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedHistoryCapabilities.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedHistoryCapabilities.cs new file mode 100644 index 000000000..76208ebb4 --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedHistoryCapabilities.cs @@ -0,0 +1,107 @@ +using Opc.Ua; +using Opc.Ua.Server; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; + +/// +/// The Boolean Server/ServerCapabilities/HistoryServerCapabilities properties (Part 11 §5.4.2) as the server +/// advertises them for one wired historian (lmxopcua#881). +/// +/// AccessHistoryDataCapability. +/// AccessHistoryEventsCapability. +/// ServerTimestampSupported. +/// InsertDataCapability. +/// ReplaceDataCapability. +/// UpdateDataCapability. +/// DeleteRawCapability. +/// DeleteAtTimeCapability. +internal sealed record HistoryCapabilityFlags( + bool AccessHistoryData, + bool AccessHistoryEvents, + bool ServerTimestampSupported, + bool InsertData, + bool ReplaceData, + bool UpdateData, + bool DeleteRaw, + bool DeleteAtTime) +{ + // Insert/Replace/Update/DeleteEvent and InsertAnnotation have no members: the node manager dispatches no event or + // annotation HistoryUpdate to any backend (the SDK base leaves UpdateEventDetails / UpdateStructureDataDetails + // BadHistoryOperationUnsupported), so they are always advertised false. +} + +/// +/// Derive and write the HistoryServerCapabilities Booleans from what the wired backend DECLARES — the +/// source's and the updater's +/// . The same declarations gate HistoryRead-Events (notifier +/// registration) and HistoryUpdate dispatch, so the advertisement cannot claim an operation the server refuses, or +/// hide one it serves. AggregateFunctions () and +/// MaxReturnDataValues () have their own sync; +/// MaxReturnEventValues stays the SDK's 0 (no limit: NumValuesPerNode = 0 returns every event). +/// +internal static class AdvertisedHistoryCapabilities +{ + /// The flags for + — pure, unit-testable. + /// The wired data source (the Null source declares nothing). + /// The wired updater, or null when the backend has no HistoryUpdate path. + /// The flags to advertise. + public static HistoryCapabilityFlags For(IHistorianDataSource source, IHistorianDataUpdater? updater) + { + var read = source.ReadCapabilities; + var update = updater?.Capabilities ?? HistoryUpdateCapabilities.None; + bool Has(HistoryUpdateCapabilities op) => (update & op) == op; + return new HistoryCapabilityFlags( + AccessHistoryData: (read & HistorianReadCapabilities.Data) != 0, + AccessHistoryEvents: (read & HistorianReadCapabilities.Events) != 0, + ServerTimestampSupported: (read & HistorianReadCapabilities.ServerTimestamps) != 0, + InsertData: Has(HistoryUpdateCapabilities.InsertData), + ReplaceData: Has(HistoryUpdateCapabilities.ReplaceData), + UpdateData: Has(HistoryUpdateCapabilities.UpdateData), + DeleteRaw: Has(HistoryUpdateCapabilities.DeleteRaw), + DeleteAtTime: Has(HistoryUpdateCapabilities.DeleteAtTime)); + } + + /// + /// Write onto the server's HistoryServerCapabilities node (the event / annotation + /// properties are written false). Idempotent; a re-sync overwrites. Never throws. + /// + /// The running server. + /// The flags. + /// true when written; false when the diagnostics manager or node is missing, or the SDK failed. + public static bool Apply(IServerInternal? server, HistoryCapabilityFlags flags) + { + if (server?.DiagnosticsNodeManager is not DiagnosticsNodeManager diagnostics) return false; + try + { + var caps = diagnostics.GetDefaultHistoryCapabilitiesAsync().AsTask().GetAwaiter().GetResult(); + if (caps is null) return false; + + void Set(PropertyState? property, bool value) + { + if (property is null) return; // optional in the nodeset — nothing to advertise + property.Value = value; + property.ClearChangeMasks(diagnostics.SystemContext, false); + } + + Set(caps.AccessHistoryDataCapability, flags.AccessHistoryData); + Set(caps.AccessHistoryEventsCapability, flags.AccessHistoryEvents); + Set(caps.ServerTimestampSupported, flags.ServerTimestampSupported); + Set(caps.InsertDataCapability, flags.InsertData); + Set(caps.ReplaceDataCapability, flags.ReplaceData); + Set(caps.UpdateDataCapability, flags.UpdateData); + Set(caps.DeleteRawCapability, flags.DeleteRaw); + Set(caps.DeleteAtTimeCapability, flags.DeleteAtTime); + Set(caps.InsertEventCapability, false); + Set(caps.ReplaceEventCapability, false); + Set(caps.UpdateEventCapability, false); + Set(caps.DeleteEventCapability, false); + Set(caps.InsertAnnotationCapability, false); + return true; + } + catch (Exception) + { + return false; // advisory: the caller logs; dispatch enforces the declarations regardless + } + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 5ae0c9c71..142786423 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -412,6 +412,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 _historianDataSource = value ?? NullHistorianDataSource.Instance; SyncAdvertisedAggregates(); RefreshEventHistoryCapability(); + SyncAdvertisedHistoryCapabilities(); } } @@ -425,7 +426,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// private void RefreshEventHistoryCapability() { - var wired = _historianDataSource is not NullHistorianDataSource; + var wired = ServesEventHistory(_historianDataSource); lock (Lock) { foreach (var (nodeId, folder) in _notifierFolders) @@ -457,7 +458,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// private void RefreshHistoryWriteCapability() { - var historyWritable = _historianDataUpdater is not null; + var historyWritable = HistoryWritable(_historianDataUpdater); lock (Lock) { foreach (var (key, variable) in _variables) @@ -478,6 +479,47 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 internal bool IsEventHistorySource(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => _eventNotifierSources.ContainsKey(MapKey(realm, folderNodeId)); + /// Whether declares event history — the ONE rule the notifier HistoryRead bit, + /// the event-history source registration and AccessHistoryEventsCapability share (lmxopcua#881). + private static bool ServesEventHistory(IHistorianDataSource source) => + (source.ReadCapabilities & HistorianReadCapabilities.Events) != 0; + + /// Whether performs any update — what the HistoryWrite AccessLevel bit of a + /// historized node follows. An updater that declares no operation admits nothing, so the bit stays off. + private static bool HistoryWritable(IHistorianDataUpdater? updater) => + updater is not null && updater.Capabilities != HistoryUpdateCapabilities.None; + + /// + /// lmxopcua#881 — write the Boolean HistoryServerCapabilities properties from the wired source's + /// and updater's + /// (). Re-run on every source or updater assignment; the Null default + /// with no updater advertises all-false, the SDK's own default. Never throws: a failure is logged and the + /// previous advertisement stays — HistoryRead / HistoryUpdate dispatch enforce the same declarations regardless. + /// + internal void SyncAdvertisedHistoryCapabilities() + { + try + { + var flags = AdvertisedHistoryCapabilities.For(_historianDataSource, _historianDataUpdater); + if (AdvertisedHistoryCapabilities.Apply(Server, flags)) + { + _logger.LogInformation( + "OtOpcUaNodeManager: advertising history capabilities for historian {Source} (updater {Updater}): {Flags}", + _historianDataSource.GetType().Name, _historianDataUpdater?.GetType().Name ?? "none", flags); + } + else + { + _logger.LogWarning( + "OtOpcUaNodeManager: could not advertise HistoryServerCapabilities for historian {Source}; HistoryRead/HistoryUpdate still enforce the backend's declared capabilities", + _historianDataSource.GetType().Name); + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OtOpcUaNodeManager: failed to sync the advertised history capabilities"); + } + } + /// /// Trim HistoryServerCapabilities/AggregateFunctions (i=11201) to the current source's /// (null ⇒ all, ). @@ -540,6 +582,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { _historianDataUpdater = value; RefreshHistoryWriteCapability(); + SyncAdvertisedHistoryCapabilities(); } } @@ -3878,7 +3921,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { if (!_notifierFolders.TryAdd(folder.NodeId, folder)) return; folder.EventNotifier = EventNotifiers.SubscribeToEvents; - if (_historianDataSource is not NullHistorianDataSource) + if (ServesEventHistory(_historianDataSource)) { // A historian is wired: advertise event history on this notifier and register it as a source. // The equipment-folder NodeId identifier IS the equipment id IS the ReadEventsAsync sourceName. @@ -4056,7 +4099,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 var historized = historianTagname is not null; // The SDK exposes the flags separately (no CurrentReadWrite composite): ReadWrite is // CurrentRead | CurrentWrite, with the HistoryRead bit OR-ed in when historized. - var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null); + var access = ComposeAccessLevel(writable, historized, HistoryWritable(_historianDataUpdater)); var variable = new EquipmentVariableState(parent, Lock) { NodeId = new NodeId(variableNodeId, ns), @@ -4154,7 +4197,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { if (!_variables.TryGetValue(key, out var v)) return false; var historized = historianTagname is not null; - var access = ComposeAccessLevel(writable, historized, _historianDataUpdater is not null); + var access = ComposeAccessLevel(writable, historized, HistoryWritable(_historianDataUpdater)); v.AccessLevel = access; v.UserAccessLevel = access; v.Historizing = historized; @@ -8140,18 +8183,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 { // One "now" per request, so every item of the batch is judged against the same bound. var latest = LatestAcceptedSourceTime(); - ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => + ServeHistoryUpdate(context, nodesToProcess, results, errors, (handle, declared) => { var details = nodesToUpdate[handle.Index]; HistoryUpdateMode mode; + HistoryUpdateCapabilities needs; switch (details.PerformInsertReplace) { - case PerformUpdateType.Insert: mode = HistoryUpdateMode.Insert; break; - case PerformUpdateType.Replace: mode = HistoryUpdateMode.Replace; break; - case PerformUpdateType.Update: mode = HistoryUpdateMode.Update; break; + case PerformUpdateType.Insert: mode = HistoryUpdateMode.Insert; needs = HistoryUpdateCapabilities.InsertData; break; + case PerformUpdateType.Replace: mode = HistoryUpdateMode.Replace; needs = HistoryUpdateCapabilities.ReplaceData; break; + case PerformUpdateType.Update: mode = HistoryUpdateMode.Update; needs = HistoryUpdateCapabilities.UpdateData; break; default: return null; // Remove (and anything unknown) ⇒ unsupported } + // lmxopcua#881: an operation the backend does not declare (and so does not advertise) is unsupported. + if (!Declares(declared, needs)) return null; + var items = details.UpdateValues.ToArray() ?? []; return (updater, tagname, who, ct) => UpdateDataAsync(updater, tagname, mode, items, latest, who, ct); }); @@ -8166,12 +8213,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 List nodesToProcess, IDictionary cache) { - ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => + ServeHistoryUpdate(context, nodesToProcess, results, errors, (handle, declared) => { var details = nodesToUpdate[handle.Index]; // Deleting the modified-value trail itself (Part 11 IsDeleteModified) is not supported: the trail is the // audit record of every change and is aged out only by retention. - if (details.IsDeleteModified) return null; + if (details.IsDeleteModified || !Declares(declared, HistoryUpdateCapabilities.DeleteRaw)) return null; var start = SdkValueBoundary.ToClrTimestamp(details.StartTime); var end = SdkValueBoundary.ToClrTimestamp(details.EndTime); // Part 11 requires both bounds of a raw delete; DateTime.MinValue is the wire's "unspecified". Refuse @@ -8195,8 +8242,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 List nodesToProcess, IDictionary cache) { - ServeHistoryUpdate(context, nodesToProcess, results, errors, handle => + ServeHistoryUpdate(context, nodesToProcess, results, errors, (handle, declared) => { + if (!Declares(declared, HistoryUpdateCapabilities.DeleteAtTime)) return null; var times = (nodesToUpdate[handle.Index].ReqTimes.ToArray() ?? []).Select(t => t.ToDateTime()).ToList(); return async (updater, tagname, who, ct) => { @@ -8215,6 +8263,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 }); } + /// Whether declares (lmxopcua#881). + private static bool Declares(IHistorianDataUpdater updater, HistoryUpdateCapabilities operation) => + (updater.Capabilities & operation) == operation; + /// /// A HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a /// value, which is converted (comparing its raw ticks against a UTC bound would be @@ -8318,13 +8370,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 /// The handles the base dispatcher admitted. /// The service-level results list. /// The service-level errors list. - /// Builds a node's operation from its details, or returns null when unsupported. + /// Builds a node's operation from its details and the wired updater (whose declared + /// it checks), or returns null when unsupported. private void ServeHistoryUpdate( ServerSystemContext context, List nodesToProcess, IList results, IList errors, - Func>?> plan) + Func>?> plan) { nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors); if (nodesToProcess.Count == 0) return; @@ -8338,7 +8391,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3 Func> Operation)>(nodesToProcess.Count); foreach (var handle in nodesToProcess) { - var operation = updater is null ? null : plan(handle); + var operation = updater is null ? null : plan(handle, updater); if (operation is null || !TryGetHistorizedTagname(handle.NodeId, out var tagname) || tagname is null) { errors[handle.Index] = StatusCodes.BadHistoryOperationUnsupported; diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs index 44c5d4107..fb6de4f6a 100644 --- a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions.Tests/Historian/HistorianDataSourceReverseEventsGuardTests.cs @@ -95,6 +95,22 @@ public sealed class HistorianDataSourceReverseEventsGuardTests } } + [Fact] + public void Every_implementation_declares_its_read_capabilities_itself() + { + // lmxopcua#881: the default (Data | Events) is what HistoryServerCapabilities advertises and what promotes + // notifier folders to event history — a Null-like wrapper that inherited it would claim history it has not got, + // and a SQL wrapper would silently drop ServerTimestampSupported. + foreach (var type in ProductionImplementations()) + { + var map = type.GetInterfaceMap(typeof(IHistorianDataSource)); + var getter = map.TargetMethods[Array.FindIndex(map.InterfaceMethods, + m => m.Name == "get_" + nameof(IHistorianDataSource.ReadCapabilities))]; + getter.DeclaringType.ShouldBe(type, + $"{type.FullName} must declare ReadCapabilities itself, not inherit the interface default"); + } + } + [Fact] public void Every_implementation_declares_its_raw_page_limit_itself() { 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 2af2f5689..1e82497e7 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/AvevaGatewayHistoryLiveGateTests.cs @@ -159,6 +159,17 @@ public sealed class AvevaGatewayHistoryLiveGateTests report.Add(new Case("metadata.historyServerCapabilities", null, null, "wire", "Good", null, caps)); Check(failures, caps["AggregateFunctions"] == string.Join(",", Served.Select(a => a.Name).OrderBy(n => n, StringComparer.Ordinal)), $"advertised history aggregates must be exactly the Gateway's ten, got {caps["AggregateFunctions"]}"); + // lmxopcua#881: the Boolean capabilities follow the Gateway backend — data + event reads, no HistoryUpdate path, no + // stored server timestamps, never event / annotation updates (this gate recorded all-false before the fix). + foreach (var flag in new[] { "AccessHistoryDataCapability", "AccessHistoryEventsCapability" }) + Check(failures, caps.GetValueOrDefault(flag) == "Boolean:True", $"{flag} must be true on the Gateway backend, got {caps.GetValueOrDefault(flag)}"); + foreach (var flag in new[] + { + "ServerTimestampSupported", "InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability", + "DeleteRawCapability", "DeleteAtTimeCapability", "InsertEventCapability", "ReplaceEventCapability", + "UpdateEventCapability", "DeleteEventCapability", "InsertAnnotationCapability", + }) + Check(failures, caps.GetValueOrDefault(flag) == "Boolean:False", $"{flag} must be false on the Gateway backend, got {caps.GetValueOrDefault(flag)}"); foreach (var (tag, id) in nodes) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryServerCapabilitiesWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryServerCapabilitiesWireTests.cs new file mode 100644 index 000000000..612167e2a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryServerCapabilitiesWireTests.cs @@ -0,0 +1,166 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; +using ZB.MOM.WW.OtOpcUa.Runtime.Historian; +using static ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests.ServerEventRig; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// lmxopcua#881 over a real SDK client session: the Boolean Server/ServerCapabilities/HistoryServerCapabilities +/// properties follow the wired backend. The data source AND the updater are built through +/// exactly as the Host builds them (Program.cs wires +/// provider.CreateUpdater), so each expectation is what that backend really serves: +/// +/// Gateway — data + event reads; no HistoryUpdate path (its provider returns no updater); AVEVA keeps no +/// per-sample server time. +/// SqlServer / QuestDb — data + event reads; Insert / Replace / Update / DeleteRaw / DeleteAtTime +/// (QuestDB deletes are tombstones); every sample stores its server time. +/// None of them — event or annotation updates: the server dispatches none. +/// +/// +public sealed class HistoryServerCapabilitiesWireTests +{ + private static readonly (string Name, NodeId Id)[] Flags = + [ + ("AccessHistoryDataCapability", VariableIds.HistoryServerCapabilities_AccessHistoryDataCapability), + ("AccessHistoryEventsCapability", VariableIds.HistoryServerCapabilities_AccessHistoryEventsCapability), + ("ServerTimestampSupported", VariableIds.HistoryServerCapabilities_ServerTimestampSupported), + ("InsertDataCapability", VariableIds.HistoryServerCapabilities_InsertDataCapability), + ("ReplaceDataCapability", VariableIds.HistoryServerCapabilities_ReplaceDataCapability), + ("UpdateDataCapability", VariableIds.HistoryServerCapabilities_UpdateDataCapability), + ("DeleteRawCapability", VariableIds.HistoryServerCapabilities_DeleteRawCapability), + ("DeleteAtTimeCapability", VariableIds.HistoryServerCapabilities_DeleteAtTimeCapability), + ("InsertEventCapability", VariableIds.HistoryServerCapabilities_InsertEventCapability), + ("ReplaceEventCapability", VariableIds.HistoryServerCapabilities_ReplaceEventCapability), + ("UpdateEventCapability", VariableIds.HistoryServerCapabilities_UpdateEventCapability), + ("DeleteEventCapability", VariableIds.HistoryServerCapabilities_DeleteEventCapability), + ("InsertAnnotationCapability", VariableIds.HistoryServerCapabilities_InsertAnnotationCapability), + ]; + + private static readonly string[] ReadOnlyHistory = ["AccessHistoryDataCapability", "AccessHistoryEventsCapability"]; + + private static readonly string[] UpdatableHistory = + [ + "AccessHistoryDataCapability", "AccessHistoryEventsCapability", "ServerTimestampSupported", + "InsertDataCapability", "ReplaceDataCapability", "UpdateDataCapability", "DeleteRawCapability", "DeleteAtTimeCapability", + ]; + + /// The flags expected TRUE per backend; every other flag in must read false. + private static string[] ExpectedTrue(string backend) => backend == "Gateway" ? ReadOnlyHistory : UpdatableHistory; + + private static IHistorianBackendProvider Provider(string backend) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["ServerHistorian:SqlServer:ConnectionString"] = "Server=localhost;User Id=sa;Password=x;TrustServerCertificate=True", + ["ServerHistorian:QuestDb:ConnectionString"] = "Host=localhost;Port=18812;Username=admin;Password=quest;Database=qdb", + }).Build(); + var options = new ServerHistorianOptions + { + Backend = backend, Enabled = true, Endpoint = "http://localhost:5222", ApiKey = "histgw_id_secret", UseTls = false, + }; + // Constructing the seams opens no connection; nothing here reaches a database or a gateway. + return HistorianBackendRegistry.Resolve(options, config); + } + + private static void Wire(ServerEventRig rig, string backend) + { + var sp = new ServiceCollection().BuildServiceProvider(); + var provider = Provider(backend); + rig.Nm.HistorianDataSource = provider.CreateDataSource(sp); + rig.Nm.HistorianDataUpdater = provider.CreateUpdater(sp); + } + + [Theory] + [InlineData("Gateway")] + [InlineData("SqlServer")] + [InlineData("QuestDb")] + public async Task Each_backend_advertises_exactly_what_it_serves(string backend) + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync($"HistCaps{backend}", encrypted: false, ct); + try + { + Wire(rig, backend); + rig.Enforce(false, [Key()]); + using var session = await rig.ConnectAsync(anonymous: true, ct); + + var read = await ReadFlagsAsync(session, ct); + var expected = ExpectedTrue(backend); + foreach (var (name, _) in Flags) + read[name].ShouldBe(expected.Contains(name), $"{backend}: {name}"); + } + finally { await rig.DisposeAsync(); } + } + + [Fact] + public async Task With_no_historian_wired_every_flag_stays_false() + { + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("HistCapsNull", encrypted: false, ct); + try + { + // The Host's disabled-historian wiring: the Null source and no updater, assigned explicitly. + rig.Nm.HistorianDataSource = null!; + rig.Nm.HistorianDataUpdater = null; + rig.Enforce(false, [Key()]); + using var session = await rig.ConnectAsync(anonymous: true, ct); + + (await ReadFlagsAsync(session, ct)).Values.ShouldAllBe(v => v == false); + } + finally { await rig.DisposeAsync(); } + } + + [Fact] + public async Task Rewiring_SqlServer_to_Gateway_retracts_the_update_flags_on_the_same_session() + { + // Positive/negative pairing on one live session: the same nodes read true under SqlServer and false under the + // Gateway, so neither expectation can pass on a server that never writes the properties. + var ct = TestContext.Current.CancellationToken; + var rig = await StartAsync("HistCapsRewire", encrypted: false, ct); + try + { + rig.Enforce(false, [Key()]); + using var session = await rig.ConnectAsync(anonymous: true, ct); + + Wire(rig, "SqlServer"); + var sql = await ReadFlagsAsync(session, ct); + sql["InsertDataCapability"].ShouldBeTrue(); + sql["DeleteRawCapability"].ShouldBeTrue(); + sql["ServerTimestampSupported"].ShouldBeTrue(); + sql["AccessHistoryDataCapability"].ShouldBeTrue(); + + Wire(rig, "Gateway"); + var gateway = await ReadFlagsAsync(session, ct); + gateway["InsertDataCapability"].ShouldBeFalse(); + gateway["DeleteRawCapability"].ShouldBeFalse(); + gateway["ServerTimestampSupported"].ShouldBeFalse(); + gateway["AccessHistoryDataCapability"].ShouldBeTrue("read access is not an update flag"); + + rig.Nm.HistorianDataSource = null!; + rig.Nm.HistorianDataUpdater = null; + (await ReadFlagsAsync(session, ct))["AccessHistoryDataCapability"].ShouldBeFalse("unwiring retracts read access too"); + } + finally { await rig.DisposeAsync(); } + } + + private static async Task> ReadFlagsAsync(ISession session, CancellationToken ct) + { + var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, + Flags.Select(f => new ReadValueId { NodeId = f.Id, AttributeId = Attributes.Value }).ToArray(), ct); + var results = response.Results.ToList(); + var flags = new Dictionary(StringComparer.Ordinal); + for (var i = 0; i < Flags.Length; i++) + { + StatusCode.IsGood(results[i].StatusCode).ShouldBeTrue($"{Flags[i].Name}: {results[i].StatusCode}"); + flags[Flags[i].Name] = results[i].ClrValue() is true; + } + + return flags; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs index 99e9a3677..a55aa4f17 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs @@ -324,6 +324,8 @@ public sealed class HistoryUpdateOverTheWireTests private readonly List _trail = []; private int _updateCalls; + public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData; + private void Record(DateTime t, double? prior, HistoryModificationType type, HistoryUpdateContext who) => _trail.Add(new HistoryModifiedValue( new DataValueSnapshot(prior, prior is null ? StatusCodes.BadNoData.Code : StatusCodes.Good.Code, t, t), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index d7952eec1..150a38e82 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -222,6 +222,51 @@ public sealed class HistoryUpdateTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// lmxopcua#881 — the updater's declared gate dispatch as well as + /// the advertisement: an updater that declares only InsertData serves an insert (positive control) but a Replace, + /// a DeleteRaw and a DeleteAtTime are BadHistoryOperationUnsupported without reaching it. + /// + [Fact] + public async Task An_operation_the_updater_does_not_declare_is_unsupported_and_never_reaches_it() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater { Capabilities = HistoryUpdateCapabilities.InsertData }; + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + var who = KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate); + + var (inserted, _) = Insert(nm, who, nodeId, Value(T1, 1d)); + inserted[0].OperationResults.ToArray()!.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted.Code]); + updater.UpdateCalls.Count.ShouldBe(1, "the declared operation is served"); + + var (replaced, replaceErrors) = Update(nm, who, nodeId, PerformUpdateType.Replace, Value(T1, 2d)); + replaceErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + replaced[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + + var (_, rawErrors) = Call(nm, who, typeof(DeleteRawModifiedDetails), new DeleteRawModifiedDetails + { + NodeId = nodeId, StartTime = T1, EndTime = T2, IsDeleteModified = false, + }); + rawErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + + var (_, atTimeErrors) = Call(nm, who, typeof(DeleteAtTimeDetails), new DeleteAtTimeDetails + { + NodeId = nodeId, ReqTimes = new List { T1 }, + }); + atTimeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code); + + updater.UpdateCalls.Count.ShouldBe(1); + updater.DeleteRawCalls.ShouldBeEmpty(); + updater.DeleteAtTimeCalls.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + /// /// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so /// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the @@ -982,6 +1027,7 @@ public sealed class HistoryUpdateTests : IDisposable /// Records every call; completes on a pool thread like a real backend round-trip. private sealed class FakeUpdater : IHistorianDataUpdater { + public HistoryUpdateCapabilities Capabilities { get; init; } = HistoryUpdateCapabilities.AllData; public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList Values)> UpdateCalls { get; } = []; public List Callers { get; } = []; public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = []; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs index 428f74a2c..460a4b4ed 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs @@ -115,6 +115,7 @@ public sealed class NodeManagerCapabilityRefreshTests : IDisposable private sealed class StubUpdater : IHistorianDataUpdater { + public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData; 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) =>