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) =>