fix(history): advertise HistoryServerCapabilities from the wired backend (#881)
v2-ci / unit-tests (push) Has been cancelled
v2-ci / unit-tests (push) Has been cancelled
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
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// What a historian backend serves for OPC UA HistoryRead — declared by every <see cref="IHistorianDataSource"/>
|
||||
/// (<see cref="IHistorianDataSource.ReadCapabilities"/>) and advertised by the server under
|
||||
/// <c>Server/ServerCapabilities/HistoryServerCapabilities</c> (lmxopcua#881).
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum HistorianReadCapabilities
|
||||
{
|
||||
/// <summary>No history is served (the Null source).</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary>Data history (raw / modified / processed / at-time) — <c>AccessHistoryDataCapability</c>.</summary>
|
||||
Data = 1,
|
||||
|
||||
/// <summary>Event history on notifier folders — <c>AccessHistoryEventsCapability</c>.</summary>
|
||||
Events = 2,
|
||||
|
||||
/// <summary>
|
||||
/// The backend stores each sample's server timestamp and returns it on a raw read, rather than stamping the read
|
||||
/// time — <c>ServerTimestampSupported</c> (Part 11 §5.4.2).
|
||||
/// </summary>
|
||||
ServerTimestamps = 4,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Which HistoryUpdate operations a <see cref="IHistorianDataUpdater"/> performs — declared by the updater itself
|
||||
/// (<see cref="IHistorianDataUpdater.Capabilities"/>). The server both advertises these under
|
||||
/// <c>HistoryServerCapabilities</c> and refuses an undeclared operation with <c>BadHistoryOperationUnsupported</c>,
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[Flags]
|
||||
public enum HistoryUpdateCapabilities
|
||||
{
|
||||
/// <summary>No update operation.</summary>
|
||||
None = 0,
|
||||
|
||||
/// <summary><c>UpdateDataDetails</c> with <c>PerformInsertReplace = Insert</c> — <c>InsertDataCapability</c>.</summary>
|
||||
InsertData = 1,
|
||||
|
||||
/// <summary><c>UpdateDataDetails</c> with <c>PerformInsertReplace = Replace</c> — <c>ReplaceDataCapability</c>.</summary>
|
||||
ReplaceData = 2,
|
||||
|
||||
/// <summary><c>UpdateDataDetails</c> with <c>PerformInsertReplace = Update</c> — <c>UpdateDataCapability</c>.</summary>
|
||||
UpdateData = 4,
|
||||
|
||||
/// <summary><c>DeleteRawModifiedDetails</c> with <c>IsDeleteModified = false</c> — <c>DeleteRawCapability</c>.</summary>
|
||||
DeleteRaw = 8,
|
||||
|
||||
/// <summary><c>DeleteAtTimeDetails</c> — <c>DeleteAtTimeCapability</c>.</summary>
|
||||
DeleteAtTime = 16,
|
||||
|
||||
/// <summary>Every data update the server can dispatch.</summary>
|
||||
AllData = InsertData | ReplaceData | UpdateData | DeleteRaw | DeleteAtTime,
|
||||
}
|
||||
@@ -246,6 +246,19 @@ public interface IHistorianDataSource : IDisposable
|
||||
/// </remarks>
|
||||
IReadOnlySet<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.All;
|
||||
|
||||
/// <summary>
|
||||
/// What this backend serves for HistoryRead — the authority for the <c>AccessHistoryDataCapability</c>,
|
||||
/// <c>AccessHistoryEventsCapability</c> and <c>ServerTimestampSupported</c> flags the server advertises under
|
||||
/// <c>HistoryServerCapabilities</c>, and (<see cref="HistorianReadCapabilities.Events"/>) for whether notifier folders
|
||||
/// carry the <c>HistoryRead</c> EventNotifier bit and accept HistoryRead-Events (lmxopcua#881).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The default (<see cref="HistorianReadCapabilities.Data"/> | <see cref="HistorianReadCapabilities.Events"/>) 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 <see cref="HistorianReadCapabilities.None"/>.
|
||||
/// </remarks>
|
||||
HistorianReadCapabilities ReadCapabilities => HistorianReadCapabilities.Data | HistorianReadCapabilities.Events;
|
||||
|
||||
/// <summary>
|
||||
/// Read modified-value history — OPC UA HistoryReadRaw with <c>IsReadModified = true</c> (Part 11):
|
||||
/// every modification a HistoryUpdate applied to the tag inside the window, each carrying the PRIOR value.
|
||||
|
||||
@@ -32,6 +32,15 @@ public enum HistoryUpdateMode
|
||||
/// </remarks>
|
||||
public interface IHistorianDataUpdater
|
||||
{
|
||||
/// <summary>
|
||||
/// The update operations this backend performs — declared by the backend itself (no default, so it cannot be
|
||||
/// forgotten). The server advertises exactly these under <c>HistoryServerCapabilities</c>
|
||||
/// (Insert/Replace/UpdateData, DeleteRaw, DeleteAtTime) and answers an operation outside the set with
|
||||
/// <c>BadHistoryOperationUnsupported</c> without calling the backend (lmxopcua#881). Read when the updater is
|
||||
/// wired and on every update, so it must be cheap and stable.
|
||||
/// </summary>
|
||||
HistoryUpdateCapabilities Capabilities { get; }
|
||||
|
||||
/// <summary>Inserts, replaces or upserts samples for one tag.</summary>
|
||||
/// <param name="tag">Historian tag name.</param>
|
||||
/// <param name="mode">Update mode.</param>
|
||||
|
||||
@@ -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<HistoryAggregateType> SupportedAggregates => HistoryAggregateTypes.None;
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
public HistorianHealthSnapshot GetHealthSnapshot() => new(
|
||||
TotalQueries: 0,
|
||||
|
||||
@@ -721,6 +721,11 @@ public sealed class GatewayHistorianDataSource : IHistorianDataSource, IAsyncDis
|
||||
public IReadOnlySet<HistoryAggregateType> SupportedAggregates =>
|
||||
_enableNativeTimeWeighted ? ServedAggregates : RawEvaluatedAggregates;
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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;
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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
|
||||
|
||||
@@ -438,6 +438,11 @@ public sealed class QuestDbHistorianDataSource : IHistorianDataSource
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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
|
||||
|
||||
@@ -87,6 +87,10 @@ internal sealed class QuestDbHistorianDataUpdater : IHistorianDataUpdater
|
||||
public void Dispose() => sem.Release();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
// All five data updates; the deletes are soft (tombstone rows), which a reader cannot tell from a hard delete.
|
||||
public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
|
||||
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct)
|
||||
|
||||
+6
@@ -363,6 +363,12 @@ public sealed class SqlServerHistorianDataSource : IHistorianDataSource
|
||||
/// <inheritdoc />
|
||||
public IReadOnlySet<HistoryAggregateType> SupportedAggregates => AggregateFamilies.Served;
|
||||
|
||||
/// <inheritdoc />
|
||||
// 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;
|
||||
|
||||
|
||||
// <remarks>
|
||||
// <c>[start, end)</c> by <c>EventTimeUtc</c> (a reversed window is swapped), ordered by
|
||||
|
||||
+5
@@ -67,6 +67,11 @@ internal sealed class SqlServerHistorianDataUpdater : IHistorianDataUpdater
|
||||
"HistoryUpdate needs the Part 11 modification trail (apply 002_sample_modified.sql and restart).");
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
// 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;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode,
|
||||
IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct)
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
using Opc.Ua;
|
||||
using Opc.Ua.Server;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
|
||||
|
||||
/// <summary>
|
||||
/// The Boolean <c>Server/ServerCapabilities/HistoryServerCapabilities</c> properties (Part 11 §5.4.2) as the server
|
||||
/// advertises them for one wired historian (lmxopcua#881).
|
||||
/// </summary>
|
||||
/// <param name="AccessHistoryData"><c>AccessHistoryDataCapability</c>.</param>
|
||||
/// <param name="AccessHistoryEvents"><c>AccessHistoryEventsCapability</c>.</param>
|
||||
/// <param name="ServerTimestampSupported"><c>ServerTimestampSupported</c>.</param>
|
||||
/// <param name="InsertData"><c>InsertDataCapability</c>.</param>
|
||||
/// <param name="ReplaceData"><c>ReplaceDataCapability</c>.</param>
|
||||
/// <param name="UpdateData"><c>UpdateDataCapability</c>.</param>
|
||||
/// <param name="DeleteRaw"><c>DeleteRawCapability</c>.</param>
|
||||
/// <param name="DeleteAtTime"><c>DeleteAtTimeCapability</c>.</param>
|
||||
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.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Derive and write the <c>HistoryServerCapabilities</c> Booleans from what the wired backend DECLARES — the
|
||||
/// source's <see cref="IHistorianDataSource.ReadCapabilities"/> and the updater's
|
||||
/// <see cref="IHistorianDataUpdater.Capabilities"/>. 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. <c>AggregateFunctions</c> (<see cref="AdvertisedAggregateFunctions"/>) and
|
||||
/// <c>MaxReturnDataValues</c> (<see cref="HistoryPaging.AdvertiseMaxReturnDataValues"/>) have their own sync;
|
||||
/// <c>MaxReturnEventValues</c> stays the SDK's 0 (no limit: <c>NumValuesPerNode = 0</c> returns every event).
|
||||
/// </summary>
|
||||
internal static class AdvertisedHistoryCapabilities
|
||||
{
|
||||
/// <summary>The flags for <paramref name="source"/> + <paramref name="updater"/> — pure, unit-testable.</summary>
|
||||
/// <param name="source">The wired data source (the Null source declares nothing).</param>
|
||||
/// <param name="updater">The wired updater, or <c>null</c> when the backend has no HistoryUpdate path.</param>
|
||||
/// <returns>The flags to advertise.</returns>
|
||||
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));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Write <paramref name="flags"/> onto the server's <c>HistoryServerCapabilities</c> node (the event / annotation
|
||||
/// properties are written false). Idempotent; a re-sync overwrites. Never throws.
|
||||
/// </summary>
|
||||
/// <param name="server">The running server.</param>
|
||||
/// <param name="flags">The flags.</param>
|
||||
/// <returns><c>true</c> when written; <c>false</c> when the diagnostics manager or node is missing, or the SDK failed.</returns>
|
||||
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<bool>? 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// </summary>
|
||||
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
|
||||
/// </summary>
|
||||
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));
|
||||
|
||||
/// <summary>Whether <paramref name="source"/> declares event history — the ONE rule the notifier HistoryRead bit,
|
||||
/// the event-history source registration and <c>AccessHistoryEventsCapability</c> share (lmxopcua#881).</summary>
|
||||
private static bool ServesEventHistory(IHistorianDataSource source) =>
|
||||
(source.ReadCapabilities & HistorianReadCapabilities.Events) != 0;
|
||||
|
||||
/// <summary>Whether <paramref name="updater"/> performs any update — what the <c>HistoryWrite</c> AccessLevel bit of a
|
||||
/// historized node follows. An updater that declares no operation admits nothing, so the bit stays off.</summary>
|
||||
private static bool HistoryWritable(IHistorianDataUpdater? updater) =>
|
||||
updater is not null && updater.Capabilities != HistoryUpdateCapabilities.None;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#881 — write the Boolean <c>HistoryServerCapabilities</c> properties from the wired source's
|
||||
/// <see cref="IHistorianDataSource.ReadCapabilities"/> and updater's <see cref="IHistorianDataUpdater.Capabilities"/>
|
||||
/// (<see cref="AdvertisedHistoryCapabilities"/>). 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.
|
||||
/// </summary>
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trim <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) to the current source's
|
||||
/// <see cref="IHistorianDataSource.SupportedAggregates"/> (null ⇒ all, <see cref="AdvertisedAggregateFunctions.ServedBy"/>).
|
||||
@@ -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<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> 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<NodeHandle> nodesToProcess,
|
||||
IDictionary<NodeId, NodeState> 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
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Whether <paramref name="updater"/> declares <paramref name="operation"/> (lmxopcua#881).</summary>
|
||||
private static bool Declares(IHistorianDataUpdater updater, HistoryUpdateCapabilities operation) =>
|
||||
(updater.Capabilities & operation) == operation;
|
||||
|
||||
/// <summary>
|
||||
/// A HistoryUpdate timestamp as UTC. Wire timestamps are UTC; an in-process caller can hand a
|
||||
/// <see cref="DateTimeKind.Local"/> value, which is converted (comparing its raw ticks against a UTC bound would be
|
||||
@@ -8318,13 +8370,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
/// <param name="nodesToProcess">The handles the base dispatcher admitted.</param>
|
||||
/// <param name="results">The service-level results list.</param>
|
||||
/// <param name="errors">The service-level errors list.</param>
|
||||
/// <param name="plan">Builds a node's operation from its details, or returns null when unsupported.</param>
|
||||
/// <param name="plan">Builds a node's operation from its details and the wired updater (whose declared
|
||||
/// <see cref="IHistorianDataUpdater.Capabilities"/> it checks), or returns null when unsupported.</param>
|
||||
private void ServeHistoryUpdate(
|
||||
ServerSystemContext context,
|
||||
List<NodeHandle> nodesToProcess,
|
||||
IList<HistoryUpdateResult> results,
|
||||
IList<ServiceResult> errors,
|
||||
Func<NodeHandle, Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>>?> plan)
|
||||
Func<NodeHandle, IHistorianDataUpdater, Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>>?> plan)
|
||||
{
|
||||
nodesToProcess = AuthorizeHistoryUpdate(context, nodesToProcess, results, errors);
|
||||
if (nodesToProcess.Count == 0) return;
|
||||
@@ -8338,7 +8391,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
Func<IHistorianDataUpdater, string, HistoryUpdateContext, CancellationToken, Task<HistoryUpdateResult>> 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;
|
||||
|
||||
+16
@@ -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()
|
||||
{
|
||||
|
||||
+11
@@ -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)
|
||||
{
|
||||
|
||||
+166
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#881 over a real SDK client session: the Boolean <c>Server/ServerCapabilities/HistoryServerCapabilities</c>
|
||||
/// properties follow the wired backend. The data source AND the updater are built through
|
||||
/// <see cref="HistorianBackendRegistry"/> exactly as the Host builds them (Program.cs wires
|
||||
/// <c>provider.CreateUpdater</c>), so each expectation is what that backend really serves:
|
||||
/// <list type="bullet">
|
||||
/// <item>Gateway — data + event reads; no HistoryUpdate path (its provider returns no updater); AVEVA keeps no
|
||||
/// per-sample server time.</item>
|
||||
/// <item>SqlServer / QuestDb — data + event reads; Insert / Replace / Update / DeleteRaw / DeleteAtTime
|
||||
/// (QuestDB deletes are tombstones); every sample stores its server time.</item>
|
||||
/// <item>None of them — event or annotation updates: the server dispatches none.</item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
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",
|
||||
];
|
||||
|
||||
/// <summary>The flags expected TRUE per backend; every other flag in <see cref="Flags"/> must read false.</summary>
|
||||
private static string[] ExpectedTrue(string backend) => backend == "Gateway" ? ReadOnlyHistory : UpdatableHistory;
|
||||
|
||||
private static IHistorianBackendProvider Provider(string backend)
|
||||
{
|
||||
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["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<Dictionary<string, bool>> 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<string, bool>(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;
|
||||
}
|
||||
}
|
||||
+2
@@ -324,6 +324,8 @@ public sealed class HistoryUpdateOverTheWireTests
|
||||
private readonly List<HistoryModifiedValue> _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),
|
||||
|
||||
@@ -222,6 +222,51 @@ public sealed class HistoryUpdateTests : IDisposable
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// lmxopcua#881 — the updater's declared <see cref="IHistorianDataUpdater.Capabilities"/> 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.
|
||||
/// </summary>
|
||||
[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<DateTimeUtc> { T1 },
|
||||
});
|
||||
atTimeErrors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported.Code);
|
||||
|
||||
updater.UpdateCalls.Count.ShouldBe(1);
|
||||
updater.DeleteRawCalls.ShouldBeEmpty();
|
||||
updater.DeleteAtTimeCalls.ShouldBeEmpty();
|
||||
}
|
||||
finally { await host.DisposeAsync(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// <summary>Records every call; completes on a pool thread like a real backend round-trip.</summary>
|
||||
private sealed class FakeUpdater : IHistorianDataUpdater
|
||||
{
|
||||
public HistoryUpdateCapabilities Capabilities { get; init; } = HistoryUpdateCapabilities.AllData;
|
||||
public List<(string Tag, HistoryUpdateMode Mode, IReadOnlyList<HistorizationValue> Values)> UpdateCalls { get; } = [];
|
||||
public List<HistoryUpdateContext> Callers { get; } = [];
|
||||
public List<(string Tag, DateTime Start, DateTime End)> DeleteRawCalls { get; } = [];
|
||||
|
||||
@@ -115,6 +115,7 @@ public sealed class NodeManagerCapabilityRefreshTests : IDisposable
|
||||
|
||||
private sealed class StubUpdater : IHistorianDataUpdater
|
||||
{
|
||||
public HistoryUpdateCapabilities Capabilities => HistoryUpdateCapabilities.AllData;
|
||||
public Task<IReadOnlyList<uint>> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList<HistorizationValue> values, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
public Task<uint> DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) =>
|
||||
|
||||
Reference in New Issue
Block a user