Files
lmxopcua/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AdvertisedAggregateFunctions.cs
T
Joseph Doherty c019d0fc2b fix(historian): trim only HistoryServerCapabilities/AggregateFunctions; null set = all (#682 review)
ServerCapabilities/AggregateFunctions (i=2997) is Part 5's list of every
aggregate the server supports, and live AggregateFilter subscriptions still
compute all 37 SDK aggregates, so it keeps the SDK's full set; only i=11201
follows the backend. A null SupportedAggregates is treated as the interface
default (all) by both the advertisement and HistoryRead-Processed (it used to
NRE inside the batch). Docs: i=2997/i=11201 split and the boot window.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-12 09:18:53 -04:00

92 lines
4.8 KiB
C#

using System.Reflection;
using Opc.Ua;
using Opc.Ua.Server;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer;
/// <summary>
/// Gitea #682 — make <c>HistoryServerCapabilities/AggregateFunctions</c> (i=11201) list exactly the aggregates the
/// wired historian backend serves for HistoryRead (<see cref="IHistorianDataSource.SupportedAggregates"/>), instead of
/// the SDK's full registered set.
/// </summary>
/// <remarks>
/// <para>
/// ⚠️ <c>ServerCapabilities/AggregateFunctions</c> (i=2997) is deliberately NOT trimmed. Part 5 defines it as every
/// aggregate the SERVER supports, and the server still supports all the SDK's registered aggregates for live
/// data: a MonitoredItem <c>AggregateFilter</c> is validated and computed by the SDK's <c>AggregateManager</c>,
/// not the historian. The SDK itself models the split — <c>DiagnosticsNodeManager.AddAggregateFunction</c> always
/// adds to i=2997 and adds to i=11201 only when the aggregate is historical. i=11201 is the history surface, so it
/// is the one that follows the backend.
/// </para>
/// <para>
/// Only the folder ⇄ aggregate <c>Organizes</c> reference pair is removed or restored (both directions); the
/// calculator factories and the aggregate-function object nodes stay. Idempotent and reversible, so re-wiring a
/// different source re-syncs the folder.
/// </para>
/// </remarks>
internal static class AdvertisedAggregateFunctions
{
/// <summary>Every standard aggregate-function NodeId the SDK's nodeset knows (<c>ObjectIds.AggregateFunction_*</c>).</summary>
private static readonly NodeId[] StandardAggregateIds = typeof(ObjectIds)
.GetFields(BindingFlags.Public | BindingFlags.Static)
.Where(f => f.FieldType == typeof(NodeId) && f.Name.StartsWith("AggregateFunction_", StringComparison.Ordinal))
.Select(f => (NodeId)f.GetValue(null)!)
.ToArray();
/// <summary>The trimmed folder — the HISTORY aggregate list only (see remarks for why not i=2997).</summary>
internal static readonly NodeId Folder = ObjectIds.HistoryServerCapabilities_AggregateFunctions;
/// <summary>
/// The set a source serves, treating a <c>null</c> <see cref="IHistorianDataSource.SupportedAggregates"/> (an
/// out-of-tree source that returns null despite the non-nullable contract) as <see cref="HistoryAggregateTypes.All"/>,
/// the interface default — the ONE null rule both the advertisement and HistoryRead-Processed use.
/// </summary>
/// <param name="source">The historian source.</param>
/// <returns>Its served set, never null.</returns>
public static IReadOnlySet<HistoryAggregateType> ServedBy(IHistorianDataSource source) =>
source.SupportedAggregates ?? HistoryAggregateTypes.All;
/// <summary>
/// Sync i=11201 to <paramref name="served"/>: an aggregate-function node is organized under it iff the server maps
/// it (<see cref="AggregateFunctionMap"/>) AND <paramref name="served"/> contains it.
/// </summary>
/// <param name="server">The running server.</param>
/// <param name="served">The backend's served aggregates.</param>
/// <returns>The advertised NodeIds, or <c>null</c> when the diagnostics node manager or folder is not available.</returns>
public static IReadOnlyList<NodeId>? Apply(IServerInternal server, IReadOnlySet<HistoryAggregateType> served)
{
if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null;
var advertised = new List<NodeId>();
lock (diagnostics.Lock)
{
var folder = diagnostics.FindPredefinedNode(Folder, typeof(BaseObjectState));
if (folder is null) return null;
foreach (var aggregateId in StandardAggregateIds)
{
var aggregateNode = diagnostics.FindPredefinedNode(aggregateId, null);
if (aggregateNode is null) continue;
var advertise = AggregateFunctionMap.Map(aggregateId) is { } aggregate && served.Contains(aggregate);
if (advertise) advertised.Add(aggregateId);
var linked = folder.ReferenceExists(ReferenceTypeIds.Organizes, false, aggregateId);
if (advertise && !linked)
{
folder.AddReference(ReferenceTypeIds.Organizes, false, aggregateId);
aggregateNode.AddReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
else if (!advertise && linked)
{
folder.RemoveReference(ReferenceTypeIds.Organizes, false, aggregateId);
aggregateNode.RemoveReference(ReferenceTypeIds.Organizes, true, folder.NodeId);
}
}
}
return advertised;
}
}