/* ======================================================================== * 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.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua; using Opc.Ua.OpenUsd; using Opc.Ua.OpenUsd.Server; using Opc.Ua.Pumps; using Opc.Ua.Server; using Opc.Ua.Server.Fluent; using Opc.Ua.Server.NodeManager; namespace Pumps { /// /// Wires the draft OPC UA — OpenUSD Bindings composition/aggregation model /// (spec §5.12–5.14) onto the server: the pump composed 1:1 of Impeller and /// Bearing component Objects (child prims), and a ProductionLine aggregating /// 1..n pumps (Many, instanceable) with a dynamically added/removed pump /// (model-change events) and a cross-server component (federation). /// public partial class PumpNodeManager { private BaseObjectState? m_productionLine; private FolderState? m_linePumps; private NodeId m_dynamicPumpNodeId; private const string LinePrimPath = "/Plant/Line1"; // 1:1 (Child): create Impeller + Bearing component Objects on the pump, each // with its own representation, and declare One bindings. private void AttachPumpComponents( PumpState pump, OpenUsdRepresentationState pumpRep, ushort ns, string primPath) { (BaseObjectState _, OpenUsdRepresentationState impellerRep) = CreateRepresentedComponent( pump, "Impeller", pump.BrowseName.NamespaceIndex, primPath + "/Impeller", ns); (BaseObjectState _, OpenUsdRepresentationState bearingRep) = CreateRepresentedComponent( pump, "Bearing", pump.BrowseName.NamespaceIndex, primPath + "/Bearing", ns); CreateComponentBinding(pumpRep, ns, "ImpellerComponent", GuidFor("ImpellerComponent"), OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Child, primPath + "/Impeller", componentRepresentation: impellerRep.NodeId); CreateComponentBinding(pumpRep, ns, "BearingComponent", GuidFor("BearingComponent"), OpenUsdCardinalityEnum.One, OpenUsdCompositionArcEnum.Child, primPath + "/Bearing", componentRepresentation: bearingRep.NodeId); } /// /// Composes one full-fidelity pump prim per configured pump. /// /// /// /// The DeviceSet carries a plant-level representation anchored on /// /Plant with a single Many component binding scoped to /// PumpType. A connector resolves that binding against the /// DeviceSet's children and composes /// /Plant/Pumps/<BrowseName> for each pump from the served /// pump.usda component asset, so the rendered scene follows /// --pumps N without the stage having to author anything per pump. /// /// /// The arc is Reference, not Instance: an instanceable prim /// turns its descendants into a shared prototype, and a shared prototype /// cannot carry the per-pump impeller rotation, casing colour or gauge /// needles that make each machine read as its own. /// /// private void MaterialisePlantAggregation(INodeManagerBuilder builder) { if (m_plantStage == null) { return; } try { ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); NodeState? deviceSet = PredefinedNodes.FindById(NodeId.Create( Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris)); if (deviceSet == null) { return; } OpenUsdRepresentationState plantRep = SystemContext.CreateRepresentation( deviceSet, m_plantStage.NodeId, PlantPrimPath, ns); NodeId pumpTypeId = NodeId.Create( Opc.Ua.Pumps.ObjectTypes.PumpType, Opc.Ua.Pumps.Namespaces.Pumps, Server.NamespaceUris); // Not dynamic: the configured pump set is fixed by --pumps N at // start-up, and the dynamic add/remove path is already demonstrated // by the ProductionLine. Declaring it dynamic here would also make // the connector's stale-prim reconciliation sweep every prim under // /Plant/Pumps — including the Impeller and Bearing component prims // this scope now contains — and deactivate them. CreateComponentBinding(plantRep, ns, "ConfiguredPumps", new Guid("a1b2c3d4-0004-4000-8000-000000000001"), OpenUsdCardinalityEnum.Many, OpenUsdCompositionArcEnum.Reference, "Pumps", assetReference: "@pump.usda@", componentTypeDefinition: pumpTypeId); // Staging assigns the whole subtree its final NodeIds - the same // pass AssignInstanceChildNodeIds used to run here - and leaves // the registration to CreateAddressSpaceAsync. builder.Add(plantRep, deviceSet.NodeId); if (m_openUsdRoot?.Representations is FolderState registry) { plantRep.RegisterInDiscovery(registry); } m_logger.MaterialisedPlantAggregation(m_twins.Count); } catch (Exception ex) { m_logger.LogError(ex, "Failed to materialise the plant aggregation."); } } // 1..n + dynamic aggregation: a ProductionLine aggregating pumps. The // aggregation is an address-space demo only - it carries no OpenUSD // representation, so the rendered hall holds exactly the pumps the // connected server simulates and nothing else. A line pump is a static // topology entry, not a machine anyone is driving; rendering it put // phantom pumps in the twin that no client could account for. // The subtree is staged on the builder rather than registered here: the // manager's CreateAddressSpaceAsync registers everything staged during // Configure and runs the reverse-reference pass over it, and staging // mints the NodeIds through the same factory this method used to call // by hand. private void MaterialiseProductionLine(INodeManagerBuilder builder) { if (m_plantStage == null) { return; } try { ushort ns = (ushort)Server.NamespaceUris.GetIndex(Opc.Ua.OpenUsd.Namespaces.OpenUSD); NodeState? deviceSet = PredefinedNodes.FindById(NodeId.Create( Opc.Ua.Di.Objects.DeviceSet, DiNamespaceUri, Server.NamespaceUris)); if (deviceSet == null) { return; } BaseObjectState line = builder .AddObject(new QualifiedName("ProductionLine", ns), deviceSet.NodeId) .Node; FolderState pumps = builder .AddFolder(new QualifiedName("Pumps", ns), line.NodeId) .Node; m_linePumps = pumps; // Two static aggregated entries (1..n baseline). CreateAggregatedPump(builder, pumps.NodeId, "P-201", ns); CreateAggregatedPump(builder, pumps.NodeId, "P-202", ns); // Dynamic composition: emit model-change events on runtime add/remove. ModelChangeEmissionEnabled = true; _ = RunDynamicCompositionAsync(ns); m_productionLine = line; m_logger.LogInformation("Materialised ProductionLine (aggregates 1..n pumps)."); } catch (Exception ex) { m_logger.LogError(ex, "Failed to materialise the ProductionLine."); } } // An aggregated line entry: a plain topology Object with no // representation, because it is not a machine the server simulates. private static void CreateAggregatedPump( INodeManagerBuilder builder, NodeId parentId, string name, ushort ns) { BaseObjectState entry = builder .AddObject(new QualifiedName(name, ns), parentId) .Node; entry.ReferenceTypeId = ReferenceTypeIds.Organizes; } // Dynamic demo (§5.13): repeatedly add a pump (emits a GeneralModelChange), // hold, then remove it (emits again), so a connector observes both the add and // the remove regardless of when it connects. Bounded so the server quiesces. private async Task RunDynamicCompositionAsync(ushort ns) { try { for (int i = 0; i < 3; i++) { await Task.Delay(4000).ConfigureAwait(false); m_dynamicPumpNodeId = await AddLinePumpAsync("P-203", LinePrimPath + "/Pumps/P_203", ns).ConfigureAwait(false); await Task.Delay(4000).ConfigureAwait(false); if (!m_dynamicPumpNodeId.IsNull) { await DeleteNodeAsync(SystemContext, m_dynamicPumpNodeId, CancellationToken.None) .ConfigureAwait(false); m_logger.RemovedLinePump(m_dynamicPumpNodeId); m_dynamicPumpNodeId = NodeId.Null; } } // Final add: leave P-203 in place so the composed stage renders it. await Task.Delay(2000).ConfigureAwait(false); m_dynamicPumpNodeId = await AddLinePumpAsync("P-203", LinePrimPath + "/Pumps/P_203", ns).ConfigureAwait(false); m_logger.LogInformation("Dynamic composition: P-203 left added (final state)."); } catch (Exception ex) { m_logger.LogWarning(ex, "Dynamic composition demo failed."); } } private async Task AddLinePumpAsync(string name, string primPath, ushort ns) { if (m_linePumps == null) { return NodeId.Null; } var pump = new BaseObjectState(null) { SymbolicName = name, BrowseName = new QualifiedName(name, ns), DisplayName = new LocalizedText(name), TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType }; _ = SystemContext.CreateRepresentation( pump, m_plantStage!.NodeId, primPath, ns); NodeId newId = await CreateNodeAsync(SystemContext, m_linePumps.NodeId, ReferenceTypeIds.Organizes, new QualifiedName(name, ns), pump, CancellationToken.None) .ConfigureAwait(false); m_logger.AddedLinePump(name, newId); return newId; } private (BaseObjectState, OpenUsdRepresentationState) CreateRepresentedComponent( NodeState parent, string name, ushort objNs, string primPath, ushort openUsdNs, NodeId refType = default) { var obj = new BaseObjectState(parent) { SymbolicName = name, BrowseName = new QualifiedName(name, objNs), DisplayName = new LocalizedText(name), ReferenceTypeId = refType.IsNull ? ReferenceTypeIds.HasComponent : refType, TypeDefinitionId = Opc.Ua.ObjectTypeIds.BaseObjectType }; parent.AddChild(obj); obj.NodeId = SystemContext.NodeIdFactory.New(SystemContext, obj); OpenUsdRepresentationState rep = SystemContext.CreateRepresentation( obj, m_plantStage!.NodeId, primPath, openUsdNs); return (obj, rep); } // Thin adapter over the reusable Opc.Ua.OpenUsd.Server authoring API: the // component/composition-binding logic lives in the SDK // (OpenUsdRepresentationAuthoring.AddComponentBinding), not in this sample. private void CreateComponentBinding( OpenUsdRepresentationState rep, ushort ns, string name, Guid bindingDefinitionId, OpenUsdCardinalityEnum cardinality, OpenUsdCompositionArcEnum arc, string targetPrimPath, NodeId componentRepresentation = default, string? assetReference = null, bool dynamic = false, NodeId changeEventSource = default, string? componentServerUri = null, string? componentEndpointUrl = null, NodeId componentTypeDefinition = default) { _ = rep.AddComponentBinding( SystemContext, ns, name, bindingDefinitionId, cardinality, arc, targetPrimPath, componentRepresentation, assetReference, dynamic, changeEventSource, componentServerUri, componentEndpointUrl, componentTypeDefinition); } } internal static partial class OpenUsdCompositionLog { [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdComposition + 1, Level = LogLevel.Information, Message = "Dynamic composition: removed line pump (NodeId={NodeId}); model-change emitted.")] public static partial void RemovedLinePump(this ILogger logger, NodeId nodeId); [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdComposition + 2, Level = LogLevel.Information, Message = "Dynamic composition: added line pump '{Name}' (NodeId={NodeId}); model-change emitted.")] public static partial void AddedLinePump(this ILogger logger, string name, NodeId nodeId); [LoggerMessage(EventId = PumpDeviceIntegrationServerEventIds.OpenUsdComposition + 3, Level = LogLevel.Information, Message = "Plant aggregation composes {PumpCount} configured pump(s) from pump.usda.")] public static partial void MaterialisedPlantAggregation(this ILogger logger, int pumpCount); } }