/* ======================================================================== * Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved. * * OPC Foundation MIT License 1.00 * * Permission is hereby granted, free of charge, to any person * obtaining a copy of this software and associated documentation * files (the "Software"), to deal in the Software without * restriction, including without limitation the rights to use, * copy, modify, merge, publish, distribute, sublicense, and/or sell * copies of the Software, and to permit persons to whom the * Software is furnished to do so, subject to the following * conditions: * * The above copyright notice and this permission notice shall be * included in all copies or substantial portions of the Software. * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR * OTHER DEALINGS IN THE SOFTWARE. * * The complete license agreement can be found here: * http://opcfoundation.org/License/MIT/1.00/ * ======================================================================*/ using System; using System.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Opc.Ua; using Opc.Ua.OpenUsd; using Opc.Ua.OpenUsd.Server; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; using Opc.Ua.Server.NodeManager; namespace SiteComposition { /// /// Runtime options for the site composition sample. /// public sealed class SiteCompositionOptions { /// /// Gets or sets the endpoint of the server that owns the pumps. /// public string? PumpServerEndpointUrl { get; set; } /// /// Gets or sets the endpoint of the server that owns the generator sets. /// public string? GeneratorServerEndpointUrl { get; set; } } /// /// A supervisory node manager that owns no devices and composes the machines of /// other servers into a single scene. /// /// /// /// This is the SCADA-level counterpart to a device server. It publishes a site /// stage - ground, buildings, lighting and a control-room camera - and declares /// one cross-server component binding per subordinate server. A connector run /// with federation enabled opens a session to each of those servers, discovers /// their representations and drives their bindings into the same stage, so one /// scene shows machines owned by three different servers, live. /// /// /// Nothing is mirrored. The site server never proxies a subordinate's address /// space; it only says where the machines are and lets the connector talk to /// each owner directly, so there is no cache to invalidate and no second copy of /// the truth. /// /// public sealed partial class SiteNodeManager : FluentNodeManagerBase { private const string SiteRootLayerIdentifier = "asset-repo/Site.usd"; private const string SitePrimPath = "/Site"; private const string PumpHallPrimPath = "/Site/PumpHall"; private const string PowerhousePrimPath = "/Site/Powerhouse"; private static readonly Guid s_pumpComponentId = new("b41d7e20-0001-4f52-9a63-1c7d8e0b5a01"); private static readonly Guid s_generatorComponentId = new("b41d7e20-0002-4f52-9a63-1c7d8e0b5a02"); private readonly SiteCompositionOptions m_options; private readonly ILogger m_log; private OpenUsdRootState? m_openUsdRoot; private OpenUsdStageState? m_siteStage; private FolderState? m_areas; /// /// Initialises a new . /// /// The server the manager belongs to. /// The application configuration. /// Sample options naming the subordinate servers. public SiteNodeManager( IServerInternal server, ApplicationConfiguration configuration, IOptions? options = null) : base(server, configuration, Namespaces.Site, Opc.Ua.OpenUsd.Namespaces.OpenUSD) { m_options = options?.Value ?? new SiteCompositionOptions(); m_log = server.Telemetry.CreateLogger(); } /// /// Gets the namespace index this sample's instances live in. /// private ushort SiteNamespaceIndex => NamespaceIndexes[0]; /// protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, CancellationToken cancellationToken = default) { var nodes = new NodeStateCollection(); nodes.AddOpcUaOpenUsd(context); return new ValueTask(nodes); } /// public override async ValueTask CreateAddressSpaceAsync( IDictionary> externalReferences, CancellationToken cancellationToken = default) { await LoadPredefinedNodesAsync(SystemContext, externalReferences, cancellationToken) .ConfigureAwait(false); NodeManagerBuilder builder = CreateFluentBuilder(SiteNamespaceIndex); MaterialiseSiteTopology(builder); await MaterialiseOpenUsdFacilityAsync(cancellationToken).ConfigureAwait(false); await MaterialiseCrossServerCompositionAsync(cancellationToken).ConfigureAwait(false); LinkOpenUsdRootToServer(externalReferences); // Registers the site subtree and mirrors its inverse Organizes // reference to the Objects folder into externalReferences. await RegisterAuthoredNodesAsync(builder, cancellationToken).ConfigureAwait(false); await CompleteConfigureAsync(externalReferences, cancellationToken).ConfigureAwait(false); await builder.SealAsync(cancellationToken).ConfigureAwait(false); m_log.SiteAddressSpaceReady( m_options.PumpServerEndpointUrl ?? "(none)", m_options.GeneratorServerEndpointUrl ?? "(none)"); } /// /// Creates a small browsable site hierarchy, so the supervisory server is a /// real server rather than a bare stage host. /// /// The fluent builder staging the site subtree. private void MaterialiseSiteTopology(NodeManagerBuilder builder) { var areas = new FolderState(null) { SymbolicName = "Site", BrowseName = new QualifiedName("Site", SiteNamespaceIndex), DisplayName = new LocalizedText("Site"), TypeDefinitionId = Opc.Ua.ObjectTypeIds.FolderType, NodeId = new NodeId("Site", SiteNamespaceIndex), EventNotifier = EventNotifiers.None, }; AddArea(areas, "PumpHall", "Pump Hall", m_options.PumpServerEndpointUrl); AddArea(areas, "Powerhouse", "Powerhouse", m_options.GeneratorServerEndpointUrl); // Staging assigns the child NodeIds through this manager's own // New(...) factory and organises the folder under Objects. m_areas = builder.Add(areas).Node; } /// /// Adds one area object recording which server owns the machines in it. /// /// The site folder. /// Area browse name. /// Area display name. /// Endpoint of the server that owns the area. private void AddArea(FolderState parent, string name, string displayName, string? endpointUrl) { var area = new BaseObjectState(parent) { SymbolicName = name, BrowseName = new QualifiedName(name, SiteNamespaceIndex), DisplayName = new LocalizedText(displayName), TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType, ReferenceTypeId = ReferenceTypeIds.Organizes, }; parent.AddChild(area); // The generic variable states are abstract in this stack, so the concrete // carrier is the non-generic one with a Variant value. var source = new BaseDataVariableState(area) { SymbolicName = "SourceServer", BrowseName = new QualifiedName("SourceServer", SiteNamespaceIndex), DisplayName = new LocalizedText("SourceServer"), TypeDefinitionId = Opc.Ua.VariableTypeIds.PropertyType, ReferenceTypeId = ReferenceTypeIds.HasProperty, DataType = Opc.Ua.Types.DataTypeIds.String, ValueRank = ValueRanks.Scalar, AccessLevel = AccessLevels.CurrentRead, UserAccessLevel = AccessLevels.CurrentRead, Value = Variant.From(endpointUrl ?? string.Empty), }; area.AddChild(source); } /// /// Declares one cross-server component binding per subordinate server. /// /// /// The binding carries the endpoint of the server that owns the machines. /// A connector without federation still composes the placeholder prim and /// renders the site shell; with federation it opens a session to each named /// server and brings that server's machines in live. Failing closed on the /// client rather than the server is deliberate - the site server can /// advertise where the machines are without dictating that anyone connect. /// private async ValueTask MaterialiseCrossServerCompositionAsync( CancellationToken cancellationToken) { if (m_siteStage == null || m_areas == null) { return; } ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); OpenUsdRepresentationState site = SystemContext.CreateRepresentation( m_areas, m_siteStage.NodeId, SitePrimPath, ns); AddSubordinate(site, ns, "PumpHall", s_pumpComponentId, PumpHallPrimPath, m_options.PumpServerEndpointUrl); AddSubordinate(site, ns, "Powerhouse", s_generatorComponentId, PowerhousePrimPath, m_options.GeneratorServerEndpointUrl); if (m_openUsdRoot?.Representations is FolderState registry) { site.RegisterInDiscovery(registry); } // The representation is built after its owner was registered, so it has // to register its own subtree - otherwise it browses from nowhere and a // connector reports that the server publishes no representation at all. SystemContext.AssignInstanceChildNodeIds(site); await AddPredefinedNodeAsync(SystemContext, site, cancellationToken) .ConfigureAwait(false); } /// /// Declares one subordinate server as a component of the site. /// /// The site representation. /// OpenUSD namespace index. /// Binding name. /// Stable binding definition id. /// Scope the subordinate's machines land in. /// Endpoint of the owning server. private void AddSubordinate( OpenUsdRepresentationState site, ushort ns, string name, Guid definitionId, string primPath, string? endpointUrl) { if (string.IsNullOrEmpty(endpointUrl)) { return; } site.AddComponentBinding( SystemContext, ns, name, definitionId, OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Reference, primPath, componentRepresentation: default, assetReference: null, dynamic: false, changeEventSource: default, componentServerUri: endpointUrl, componentEndpointUrl: endpointUrl); } /// /// Creates the well-known OpenUSD facility and the site stage. /// /// Cancellation token. private async ValueTask MaterialiseOpenUsdFacilityAsync(CancellationToken cancellationToken) { try { ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); OpenUsdRootState root = SystemContext.CreateInstanceOfOpenUsdRootType( null!, new QualifiedName("OpenUSD", ns)); root.NodeId = new NodeId("OpenUSD", ns); FolderState stages = root.Stages ?? root.CreateOrReplaceStages(SystemContext, null!); _ = root.Representations ?? root.CreateOrReplaceRepresentations(SystemContext, null!); m_siteStage = SystemContext.CreateInstanceOfOpenUsdStageType( stages, new QualifiedName("SiteStage", ns)); stages.AddChild(m_siteStage); m_siteStage.CreateOrReplaceRootLayerIdentifier(SystemContext, null!) .Value = SiteRootLayerIdentifier; List served = LoadServedAssets(); byte[] rootLayerBytes = served .Find(a => a.Kind == OpenUsdAssetKindEnum.RootLayer)!.Bytes; byte[] digest; #pragma warning disable CA1850 // Prefer static HashData (net48/netstandard2.0 compatibility) using (var sha = System.Security.Cryptography.SHA256.Create()) { digest = sha.ComputeHash(rootLayerBytes); } #pragma warning restore CA1850 m_siteStage.CreateOrReplaceRootLayerDigest(SystemContext, null!) .Value = (ByteString)digest; m_siteStage.CreateOrReplaceRootLayerDigestAlgorithm(SystemContext, null!) .Value = OpenUsdDigestAlgorithmEnum.Sha256; root.AddReference(ReferenceTypeIds.HasComponent, true, Opc.Ua.ObjectIds.Server); UsdAssetDelivery.AttachStageAssets(SystemContext, m_siteStage, ns, served); SystemContext.AssignInstanceChildNodeIds(root); await AddPredefinedNodeAsync(SystemContext, root, cancellationToken) .ConfigureAwait(false); m_openUsdRoot = root; } catch (Exception ex) { m_siteStage = null; m_openUsdRoot = null; m_log.OpenUsdFacilityFailed(ex); } } /// /// Loads the site shell layer this server serves. /// /// The served asset closure. /// /// Only the site shell. The machine geometry belongs to the subordinate /// servers, which serve their own component assets to the same connector. /// private static List LoadServedAssets() { return [ new ServedAsset("Site.usda", OpenUsdAssetKindEnum.RootLayer, ReadEmbeddedAsset("Site.usda")), ]; } /// /// Reads one embedded USD layer. /// /// Logical resource name. /// The layer bytes, or an empty array when absent. private static byte[] ReadEmbeddedAsset(string resourceName) { using Stream? s = typeof(SiteNodeManager).Assembly.GetManifestResourceStream(resourceName); if (s == null) { return []; } using var ms = new MemoryStream(); s.CopyTo(ms); return ms.ToArray(); } /// /// Makes the OpenUSD facility browsable from the Server Object. /// /// The shared reference dictionary. private void LinkOpenUsdRootToServer(IDictionary> externalReferences) { if (m_openUsdRoot == null) { return; } if (!externalReferences.TryGetValue(Opc.Ua.ObjectIds.Server, out IList? refs)) { refs = new List(); externalReferences[Opc.Ua.ObjectIds.Server] = refs; } refs.Add(new NodeStateReference( ReferenceTypeIds.HasComponent, false, m_openUsdRoot.NodeId)); } } /// /// Namespaces this sample owns. /// internal static class Namespaces { /// /// The site composition sample instance namespace. /// public const string Site = "http://opcfoundation.org/UA/Samples/SiteComposition/"; } /// /// Creates the for the DI hosting pipeline. /// public sealed class SiteNodeManagerFactory : IAsyncNodeManagerFactory { private readonly IOptions? m_options; /// /// Initialises a factory without options. /// public SiteNodeManagerFactory() { } /// /// Initialises a factory with sample options. /// /// Sample options. public SiteNodeManagerFactory(IOptions? options) { m_options = options; } /// public ArrayOf NamespacesUris => new string[] { Namespaces.Site, Opc.Ua.OpenUsd.Namespaces.OpenUSD, }.ToArrayOf(); /// public ValueTask CreateAsync( IServerInternal server, ApplicationConfiguration configuration, CancellationToken cancellationToken = default) { #pragma warning disable CA2000 // ownership transferred to server IAsyncNodeManager nodeManager = new SiteNodeManager(server, configuration, m_options); #pragma warning restore CA2000 return new ValueTask(nodeManager); } } /// /// Event id offsets for the site composition server. /// /// /// Each per-file <ClassName>Log class allocates its event ids relative /// to the offset constant below, using offset + <zero-based message index>, /// so two files cannot silently claim the same id. /// internal static class SiteCompositionServerEventIds { /// /// Offset for messages raised by . /// public const int SiteNodeManager = 0; } /// /// Source-generated log messages for . /// internal static partial class SiteNodeManagerLog { [LoggerMessage( EventId = SiteCompositionServerEventIds.SiteNodeManager + 0, Level = LogLevel.Information, Message = "Site composition ready. Pump server: {PumpServer}. Generator server: {GeneratorServer}.")] public static partial void SiteAddressSpaceReady( this ILogger logger, string pumpServer, string generatorServer); [LoggerMessage( EventId = SiteCompositionServerEventIds.SiteNodeManager + 1, Level = LogLevel.Error, Message = "Failed to materialise the OpenUSD facility.")] public static partial void OpenUsdFacilityFailed(this ILogger logger, Exception exception); } }