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;
}
}