using System.Reflection; using Opc.Ua; using Opc.Ua.Server; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// Gitea #682 — make HistoryServerCapabilities/AggregateFunctions (i=11201) list exactly the aggregates the /// wired historian backend serves for HistoryRead (), instead of /// the SDK's full registered set. /// /// /// /// ⚠️ ServerCapabilities/AggregateFunctions (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 AggregateFilter is validated and computed by the SDK's AggregateManager, /// not the historian. The SDK itself models the split — DiagnosticsNodeManager.AddAggregateFunction 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. /// /// /// Only the folder ⇄ aggregate Organizes 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. /// /// internal static class AdvertisedAggregateFunctions { /// Every standard aggregate-function NodeId the SDK's nodeset knows (ObjectIds.AggregateFunction_*). 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(); /// The trimmed folder — the HISTORY aggregate list only (see remarks for why not i=2997). internal static readonly NodeId Folder = ObjectIds.HistoryServerCapabilities_AggregateFunctions; /// /// The set a source serves, treating a null (an /// out-of-tree source that returns null despite the non-nullable contract) as , /// the interface default — the ONE null rule both the advertisement and HistoryRead-Processed use. /// /// The historian source. /// Its served set, never null. public static IReadOnlySet ServedBy(IHistorianDataSource source) => source.SupportedAggregates ?? HistoryAggregateTypes.All; /// /// Sync i=11201 to : an aggregate-function node is organized under it iff the server maps /// it () AND contains it. /// /// The running server. /// The backend's served aggregates. /// The advertised NodeIds, or null when the diagnostics node manager or folder is not available. public static IReadOnlyList? Apply(IServerInternal server, IReadOnlySet served) { if (server?.DiagnosticsNodeManager is not CustomNodeManager2 diagnostics) return null; var advertised = new List(); 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; } }