diff --git a/docs/AutoID.md b/docs/AutoID.md index afa5d8b747..a88f342454 100644 --- a/docs/AutoID.md +++ b/docs/AutoID.md @@ -153,8 +153,9 @@ services.AddOpcUa() The builder materialises exactly the components the provider supports (a reader without a `Scan` provider has no `Scan` method), registers the device, makes it an event notifier and attaches the provider. A failure at any step is rolled -back, so the browse name can be used again, and concurrent builds of one name -admit exactly one. Disposing the runtime (the node manager does so on shutdown) +back, so the browse name can be used again, a provider the build owns +(`OwnsProvider()`) is disposed, and concurrent builds of one name admit exactly +one. Disposing the runtime (the node manager does so on shutdown) stops every reader's scans, detaches its provider and, for a provider the reader owns (`OwnsProvider()`), disposes it. @@ -228,8 +229,8 @@ services.AddOpcUa() | Node managers | The `MachineryNodeManager` alone; no `AutoIdNodeManager` is created | | Device Integration model, `DeviceSet` | Loaded once by the Machinery owner's core model provider | | AutoID model | Added to the owner by `AutoIdMachineryModelProvider`, with `AdditionalNamespaceUris` | -| Readers | Below the owner's `DeviceSet` (`Organizes`), NodeIds in `MachineryServerOptions.InstanceNamespaceUri`; take browse names from `context.Runtime.InstanceNamespaceIndex`. `WithParent` places a reader below another node of the owner instead, for example a component of a machine | -| `Machines` folder | Readers are devices, not machines, and are not added to it | +| Readers | Below the owner's `DeviceSet` (`Organizes`), NodeIds in `MachineryServerOptions.InstanceNamespaceUri`; take browse names from `context.Runtime.InstanceNamespaceIndex`. `WithParent` and `AsComponentOf` place a reader elsewhere and keep it reachable from `DeviceSet`, see [Placement](#placement) | +| `Machines` folder | Readers are devices, not machines, and are not added to it; `AsComponentOf` makes one a component of a machine | | Methods, events, providers | As in the stand-alone server; the readers are event notifiers of the owner | | Conformance | The runtime contributes the AutoID units and facets to the owner, which publishes them with the DI and Machinery ones | | Lifetime | The owner disposes the runtime on shutdown: scans stop, providers detach, owned providers are disposed | @@ -242,6 +243,91 @@ receives the stand-alone runtime, so configuration code can be shared by both hosting paths. `Opc.Ua.AutoID.Server` references `Opc.Ua.Machinery.Server` for this; a stand-alone server still loads only the DI and AutoID models. +### Placement + +OPC 30010 does not say where readers live in the address space; the rules come +from the specifications it builds on. + +| Rule | Source | +| --- | --- | +| "The DeviceSet Object is the starting point to locate Devices. It shall either directly or indirectly reference all instances of a subtype of ComponentType with a Hierarchical Reference." Every AutoID device type derives from DI `DeviceType`, a `ComponentType`. | OPC 10000-100 1.05.0 4.9; OPC 30010 6.1 | +| "For complex Devices that are composed of various components that are also Devices, only the root instance shall be referenced from the DeviceSet Object." | OPC 10000-100 1.05.0 4.9 | +| The `Components` object of a machine "directly references all identifiable components of a Machine", with `HasComponent`, and each component carries the `2:Identification` add-in (`HasAddIn`) whose `Manufacturer` and `SerialNumber` are mandatory. | OPC 40001-1 1.04.2 11.1, 11.2 (Tables 27, 28), 8.3 | +| Machinery and DI place no requirement on each other: a machine need not be in `DeviceSet`, and a device need not be in `Machines`. | OPC 40001-1 1.04.2, OPC 10000-100 1.05.0 | + +The SDK applies them as follows: + +| Builder call | Where the reader is | Reference from `DeviceSet` | +| --- | --- | --- | +| none (default) | Below `DeviceSet` | `Organizes` | +| `WithParent(parent)`, `DeviceSet` reaches `parent` (for example another reader) | Below `parent` (`HasComponent`) | None: the reader is a component of a composed device whose root `DeviceSet` references | +| `WithParent(parent)`, `DeviceSet` does not reach `parent` | Below `parent` (`HasComponent`) | `Organizes`, added by the runtime | +| `AsComponentOf(machine)` | Below the machine's `Components` (`HasComponent`), with an `Identification` add-in | As for `WithParent` with the `Components` object as parent: usually `Organizes`, because a machine in `Machines` is not necessarily reachable from `DeviceSet` | + +Whether `DeviceSet` reaches the parent is decided when the reader is built, by +following the forward hierarchical references of the owner's nodes. A child +attached by a non-hierarchical reference, such as a condition by `HasCondition`, +is no path from `DeviceSet`. + +Where OPC 30010 is silent, these are SDK decisions: a reader is not added to the +`Machines` folder, because no specification classifies an AutoID device as a +machine (OPC 40001-1 11.1 leaves that to the domain specifications), and a +reader is a component of a machine only when the application asks for it. + +`AsComponentOf` needs AutoID composed into the Machinery owner +(`AddAutoIdToMachinery` or a DI owner that loads the Machinery model) and a +machine of that owner that publishes the `Components` add-in, here built with +`WithComponents`. The runtime takes the add-in the machine's +`MachineryBuildingBlocks` folder references with `HasAddIn` (OPC 40001-1 7.1), +so a container a companion model declares and `WithComponents(existing, ...)` +binds is found below whatever parent and with whatever browse name it keeps; a +machine without that folder offers its `MachineComponentsType` child: + +```csharp +IMachineHandle? press = null; +builder + .ConfigureMachinery(async (machinery, ct) => + press = await machinery.AddMachine(new QualifiedName("Press1", machinery.InstanceNamespaceIndex)) + .WithIdentification(id => + { + id.Manufacturer = new LocalizedText("Acme"); + id.SerialNumber = "PR-1"; + id.ProductInstanceUri = "urn:acme:press:1"; + }) + .WithComponents(_ => { }) + .BuildAsync(ct)) + .ConfigureAutoIdFor(async (context, ct) => + await context.Runtime + .Reader(new QualifiedName("PressInfeed", context.Runtime.InstanceNamespaceIndex), hardware) + .AsComponentOf(press!.State) + .WithIdentification(nameplate => + { + nameplate.Manufacturer = new LocalizedText("Acme"); + nameplate.SerialNumber = "R100-0001"; + }) + .BuildAsync(ct)); +``` + +The reader gets the `2:Identification` add-in typed +`MachineryComponentIdentificationType`. It holds no copies: it references the +reader's own DI nameplate properties with `HasProperty`. This follows the +approach of OPC 40001-1 7.3 ("let several References lead to the same Node, +rather than duplicating the Node") and of the Robotics example in Annex B.2, +whose identification Properties "are only referenced from the Identification +functional group". It references +`Manufacturer`, `SerialNumber` and those of `Model`, `ManufacturerUri`, +`ProductCode`, `HardwareRevision`, `SoftwareRevision`, `DeviceRevision`, +`DeviceClass` and `ProductInstanceUri` that are set, plus `AssetId` and +`ComponentName` when the reader publishes them; `DeviceManual` and +`RevisionCounter` are left out as OPC 40001-1 10.2 recommends. A missing +`Manufacturer` or `SerialNumber`, a machine of another node manager, a machine +without `Components` or with more than one, or a server without the Machinery +model fail the build with +`Bad_ConfigurationError` before anything is registered. `WithParent` and +`AsComponentOf` exclude each other. The runtime does not add Machinery +conformance units; the Machinery owner advertises them for what its builders +created. + ### Direct composition and lifetime Without dependency injection, pass `new AutoIdMachineryModelProvider()` together @@ -510,4 +596,5 @@ encodings. | `AutoIdHostingTests` | `AddAutoId` registration, DI ownership, options, node manager and client factories | | `AutoIdCompositionTests` | Stock Machinery owner, ordered providers, DI loaded once, instance ownership, rollback, cancellation, runtime disposal, a second DI owner rejected | | `AutoIdMachineryCompositionEndToEndTests` | Machinery, Pumps and AutoID in one hosted server, read back by the AutoID, Machinery and Pumps clients over a real session, with the published profiles and conformance units | +| `AutoIdPlacementEndToEndTests` | Reader placement over a real session: every reader reachable from `DeviceSet`, only roots organized by it, a reader as a component of a machine with its `Identification`, no reader in `Machines` | | `AutoIdReaderEndToEndTests` | The stand-alone server driven by `AutoIdClient` over a real session | diff --git a/src/Opc.Ua.AutoID.Server/AutoIdReaderPlacement.cs b/src/Opc.Ua.AutoID.Server/AutoIdReaderPlacement.cs new file mode 100644 index 0000000000..cbbfe119b4 --- /dev/null +++ b/src/Opc.Ua.AutoID.Server/AutoIdReaderPlacement.cs @@ -0,0 +1,400 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Builders; +using Opc.Ua.Server; + +namespace Opc.Ua.AutoID.Server +{ + /// + /// Places a reader in the address space of its Device Integration owner so that it stays + /// reachable from the DI DeviceSet (OPC 10000-100 4.9), and makes it a component of a + /// machine (OPC 40001-1 11) on request. + /// + internal sealed class AutoIdReaderPlacement + { + /// + /// Creates the placement helper of a node manager. + /// + public AutoIdReaderPlacement(DiNodeManager manager) + { + m_manager = manager; + } + + /// + /// Gets the DI DeviceSet of the node manager, or when the + /// manager does not publish one. + /// + public NodeState? FindDeviceSet() + { + return m_manager.FindPredefinedNode(NodeId.Create( + Di.Objects.DeviceSet, + DiNodeManager.DiNamespaceUri, + m_manager.Server.NamespaceUris)); + } + + /// + /// Resolves the Components add-in of that a reader becomes + /// a component of, and fails before anything is registered when the reader cannot be one. + /// + /// + /// Bad_ConfigurationError: the owner has no Machinery model, the machine is not a + /// node of the owner or it publishes no Components add-in, or more than one. + /// + public NodeState ResolveComponents(BaseObjectState machine, QualifiedName readerName) + { + ServerSystemContext context = m_manager.SystemContext; + int machineryNamespace = context.NamespaceUris.GetIndex(Machinery.Namespaces.Machinery); + NodeId componentsTypeId = machineryNamespace < 0 + ? NodeId.Null + : ExpandedNodeId.ToNodeId(Machinery.ObjectTypeIds.MachineComponentsType, context.NamespaceUris); + if (componentsTypeId.IsNull || m_manager.FindPredefinedNode(componentsTypeId) == null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The reader '{0}' cannot be a component of a machine: AutoID is not composed with " + + "Machinery. Register it with AddAutoIdToMachinery, or load the Machinery model into " + + "the Device Integration owner.", + readerName); + } + if (!ReferenceEquals(m_manager.FindPredefinedNode(machine.NodeId), machine)) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The machine '{0}' of the reader '{1}' is not a node of the AutoID owner's node manager.", + machine.BrowseName, + readerName); + } + List components = FindComponentsAddIns( + machine, + (ushort)machineryNamespace, + componentsTypeId); + if (components.Count == 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The machine '{0}' publishes no Components add-in for the reader '{1}'; build the " + + "machine with WithComponents (OPC 40001-1 11).", + machine.BrowseName, + readerName); + } + if (components.Count > 1) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The machine '{0}' publishes more than one Components add-in; the reader '{1}' cannot " + + "tell which one to become a component of.", + machine.BrowseName, + readerName); + } + return components[0]; + } + + /// + /// Finds the Components add-ins of the way Machinery binds + /// them: the MachineComponentsType objects its MachineryBuildingBlocks folder + /// references with HasAddIn (OPC 40001-1 7.1), wherever they are placed and whatever + /// their browse name. A machine without that folder is searched for a hierarchically + /// referenced child of the type instead. + /// + private List FindComponentsAddIns( + BaseObjectState machine, + ushort machineryNamespace, + NodeId componentsTypeId) + { + ServerSystemContext context = m_manager.SystemContext; + TypeTable typeTree = m_manager.Server.TypeTree; + var found = new List(); + NodeState? buildingBlocks = machine.FindChild( + context, + new QualifiedName(MachineryBuildingBlocksBrowseName, machineryNamespace)); + if (buildingBlocks != null) + { + var references = new List(); + buildingBlocks.GetReferences(context, references); + foreach (IReference reference in references) + { + if (reference.IsInverse || + reference.TargetId.IsAbsolute || + !typeTree.IsTypeOf(reference.ReferenceTypeId, Opc.Ua.Types.ReferenceTypeIds.HasAddIn)) + { + continue; + } + var targetId = ExpandedNodeId.ToNodeId(reference.TargetId, context.NamespaceUris); + if (!targetId.IsNull && + m_manager.FindPredefinedNode(targetId) is BaseObjectState block && + typeTree.IsTypeOf(block.TypeDefinitionId, componentsTypeId) && + !found.Contains(block)) + { + found.Add(block); + } + } + } + if (found.Count == 0) + { + var children = new List(); + machine.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + if (child is BaseObjectState block && + IsHierarchical(typeTree, child) && + typeTree.IsTypeOf(block.TypeDefinitionId, componentsTypeId)) + { + found.Add(block); + } + } + } + return found; + } + + /// + /// Returns whether is referenced from its parent with a + /// hierarchical reference, rather than for example HasCondition. + /// + private static bool IsHierarchical(TypeTable typeTree, BaseInstanceState child) + { + return !child.ReferenceTypeId.IsNull && + typeTree.IsTypeOf(child.ReferenceTypeId, Opc.Ua.Types.ReferenceTypeIds.HierarchicalReferences); + } + + /// + /// Checks the members OPC 40001-1 8.3 makes mandatory for the identification of a + /// component before anything is registered. + /// + /// + /// Bad_ConfigurationError: Manufacturer or SerialNumber is not set. + /// + public static void ValidateComponentNameplate(DeviceIdentificationData? nameplate, QualifiedName readerName) + { + var missing = new List(); + if (nameplate == null || nameplate.Manufacturer.IsNull || string.IsNullOrEmpty(nameplate.Manufacturer.Text)) + { + missing.Add("Manufacturer"); + } + if (string.IsNullOrEmpty(nameplate?.SerialNumber)) + { + missing.Add("SerialNumber"); + } + if (missing.Count > 0) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The reader '{0}' is a component of a machine and needs the mandatory identification " + + "{1} (OPC 40001-1 8.3); set it with WithIdentification.", + readerName, + string.Join(" and ", missing)); + } + } + + /// + /// Returns whether DeviceSet reaches through forward + /// hierarchical references of nodes of the node manager. + /// + public bool IsReachableFrom(NodeState deviceSet, NodeState target) + { + if (ReferenceEquals(deviceSet, target)) + { + return true; + } + ServerSystemContext context = m_manager.SystemContext; + TypeTable typeTree = m_manager.Server.TypeTree; + var visited = new HashSet { deviceSet.NodeId }; + var pending = new Queue(); + pending.Enqueue(deviceSet); + var children = new List(); + var references = new List(); + while (pending.Count > 0) + { + NodeState node = pending.Dequeue(); + children.Clear(); + node.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + // A child can hang off its parent by a non-hierarchical reference, for example + // a condition by HasCondition; it is no path from DeviceSet. + if (!IsHierarchical(typeTree, child)) + { + continue; + } + if (ReferenceEquals(child, target)) + { + return true; + } + if (child.NodeClass != NodeClass.Method && visited.Add(child.NodeId)) + { + pending.Enqueue(child); + } + } + references.Clear(); + node.GetReferences(context, references); + foreach (IReference reference in references) + { + if (reference.IsInverse || + reference.TargetId.IsAbsolute || + !typeTree.IsTypeOf( + reference.ReferenceTypeId, + Opc.Ua.Types.ReferenceTypeIds.HierarchicalReferences)) + { + continue; + } + var targetId = ExpandedNodeId.ToNodeId(reference.TargetId, context.NamespaceUris); + NodeState? next = targetId.IsNull ? null : m_manager.FindPredefinedNode(targetId); + if (next == null) + { + continue; + } + if (ReferenceEquals(next, target)) + { + return true; + } + if (visited.Add(next.NodeId)) + { + pending.Enqueue(next); + } + } + } + return false; + } + + /// + /// Organizes from DeviceSet, in both directions. + /// + public static void Organize(NodeState deviceSet, NodeState device) + { + deviceSet.AddReference(Opc.Ua.Types.ReferenceTypeIds.Organizes, false, device.NodeId); + device.AddReference(Opc.Ua.Types.ReferenceTypeIds.Organizes, true, deviceSet.NodeId); + } + + /// + /// Removes what added. + /// + public static void RemoveOrganize(NodeState deviceSet, NodeState device) + { + deviceSet.RemoveReference(Opc.Ua.Types.ReferenceTypeIds.Organizes, false, device.NodeId); + device.RemoveReference(Opc.Ua.Types.ReferenceTypeIds.Organizes, true, deviceSet.NodeId); + } + + /// + /// Adds the OPC 40001-1 Identification add-in of a component to the registered + /// reader. It is typed MachineryComponentIdentificationType and references the + /// reader's DI nameplate properties rather than copying them (OPC 40001-1 7.3, Annex B.2). + /// + /// + /// Bad_ConfigurationError: the reader has an Identification child already. + /// + public async ValueTask AddComponentIdentificationAsync( + AutoIdDeviceState device, + CancellationToken cancellationToken) + { + ServerSystemContext context = m_manager.SystemContext; + var browseName = new QualifiedName(Di.BrowseNames.Identification, m_manager.DiNamespaceIndex); + if (device.FindChild(context, browseName) != null) + { + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The reader '{0}' has an Identification already.", + device.BrowseName); + } + var identification = new BaseObjectState(device) + { + SymbolicName = Di.BrowseNames.Identification, + BrowseName = browseName, + DisplayName = new LocalizedText(Di.BrowseNames.Identification), + TypeDefinitionId = ExpandedNodeId.ToNodeId( + Machinery.ObjectTypeIds.MachineryComponentIdentificationType, + context.NamespaceUris), + ReferenceTypeId = Opc.Ua.Types.ReferenceTypeIds.HasAddIn + }; + identification.NodeId = context.NodeIdFactory!.New(context, identification); + device.AddChild(identification); + foreach (BaseVariableState property in NameplateOf(device)) + { + identification.AddReference(Opc.Ua.Types.ReferenceTypeIds.HasProperty, false, property.NodeId); + property.AddReference(Opc.Ua.Types.ReferenceTypeIds.HasProperty, true, identification.NodeId); + } + await m_manager.AddPredefinedNodeAsync(identification, cancellationToken).ConfigureAwait(false); + } + + /// + /// The nameplate properties of the reader that its component identification references: + /// the members of MachineryComponentIdentificationType that the reader publishes + /// with a value. DeviceManual and RevisionCounter are left out, as + /// OPC 40001-1 10.2 recommends; the user-writable AssetId and ComponentName + /// are referenced whenever the reader publishes them. + /// + private static IEnumerable NameplateOf(AutoIdDeviceState device) + { + foreach (PropertyState? text in new[] { device.Manufacturer, device.Model }) + { + if (text != null && !text.Value.IsNull && !string.IsNullOrEmpty(text.Value.Text)) + { + yield return text; + } + } + foreach (PropertyState? value in new[] + { + device.SerialNumber, + device.ManufacturerUri, + device.ProductCode, + device.HardwareRevision, + device.SoftwareRevision, + device.DeviceRevision, + device.DeviceClass, + device.ProductInstanceUri + }) + { + if (value != null && !string.IsNullOrEmpty(value.Value)) + { + yield return value; + } + } + if (device.AssetId != null) + { + yield return device.AssetId; + } + if (device.ComponentName != null) + { + yield return device.ComponentName; + } + } + + /// + /// The browse name OPC 40001-1 7.1 gives the building-block organizer of a machine; the + /// model declares no type for it, so there is no generated constant. + /// + private const string MachineryBuildingBlocksBrowseName = "MachineryBuildingBlocks"; + + private readonly DiNodeManager m_manager; + } +} diff --git a/src/Opc.Ua.AutoID.Server/AutoIdRuntime.cs b/src/Opc.Ua.AutoID.Server/AutoIdRuntime.cs index 9acf71314e..af0ae2ef8a 100644 --- a/src/Opc.Ua.AutoID.Server/AutoIdRuntime.cs +++ b/src/Opc.Ua.AutoID.Server/AutoIdRuntime.cs @@ -34,6 +34,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua.AutoID.Server.Builders; using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Builders; using Opc.Ua.Machinery.Server; using Opc.Ua.Server; @@ -64,6 +65,7 @@ namespace Opc.Ua.AutoID.Server Manager = manager ?? throw new ArgumentNullException(nameof(manager)); Options = (options ?? throw new ArgumentNullException(nameof(options))).Validate(); m_logger = manager.SystemContext.Telemetry.CreateLogger(); + m_placement = new AutoIdReaderPlacement(manager); manager.RegisterConformanceContributor(this); m_sessionLease = manager.RegisterSessionClosingHandler(OnSessionClosingAsync); } @@ -317,61 +319,40 @@ namespace Opc.Ua.AutoID.Server AutoIdReaderBuilder builder, CancellationToken cancellationToken) { - AutoIdReaderKind kind = builder.Provider.Kind; - ISystemContext context = SystemContext; QualifiedName name = builder.BrowseName; AutoIdDeviceState device; Action applyIdentification; - switch (kind) + NodeState parentNode; + try { - case AutoIdReaderKind.Rfid: - (device, applyIdentification) = await CreateDeviceAsync( - builder, - ObjectTypeIds.RfidReaderDeviceType, - parent => context.CreateInstanceOfRfidReaderDeviceType(parent, name), - cancellationToken).ConfigureAwait(false); - break; - case AutoIdReaderKind.Ocr: - (device, applyIdentification) = await CreateDeviceAsync( - builder, - ObjectTypeIds.OcrReaderDeviceType, - parent => context.CreateInstanceOfOcrReaderDeviceType(parent, name), - cancellationToken).ConfigureAwait(false); - break; - case AutoIdReaderKind.Optical: - (device, applyIdentification) = await CreateDeviceAsync( - builder, - ObjectTypeIds.OpticalReaderDeviceType, - parent => context.CreateInstanceOfOpticalReaderDeviceType(parent, name), - cancellationToken).ConfigureAwait(false); - break; - case AutoIdReaderKind.OpticalVerifier: - (device, applyIdentification) = await CreateDeviceAsync( - builder, - ObjectTypeIds.OpticalVerifierDeviceType, - parent => context.CreateInstanceOfOpticalVerifierDeviceType(parent, name), - cancellationToken).ConfigureAwait(false); - break; - case AutoIdReaderKind.Rtls: - (device, applyIdentification) = await CreateDeviceAsync( - builder, - ObjectTypeIds.RtlsDeviceType, - parent => context.CreateInstanceOfRtlsDeviceType(parent, name), - cancellationToken).ConfigureAwait(false); - break; - default: - throw new ArgumentOutOfRangeException(nameof(builder), kind, "Unknown reader kind."); + (device, applyIdentification) = await CreateDeviceOfKindAsync(builder, cancellationToken) + .ConfigureAwait(false); + parentNode = device.Parent ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The reader '{0}' has no parent.", + name); + } + catch + { + if (builder.OwnsProviderOnDispose) + { + // No reader took ownership of the provider: the identification delegate, the + // component checks or the device creation failed first. + await DisposeProviderQuietlyAsync(builder.Provider, name.Name).ConfigureAwait(false); + } + throw; } - - NodeState parentNode = device.Parent ?? - throw ServiceResultException.Create( - StatusCodes.BadConfigurationError, - "The reader '{0}' has no parent.", - name); AutoIdReader? reader = null; + NodeState? organizedBy = null; try { applyIdentification(); + if (builder.Machine != null) + { + await m_placement.AddComponentIdentificationAsync(device, cancellationToken).ConfigureAwait(false); + } + organizedBy = KeepReachableFromDeviceSet(parentNode, device); reader = new AutoIdReader( this, device, @@ -400,13 +381,114 @@ namespace Opc.Ua.AutoID.Server // The reader never took ownership of the provider. await DisposeProviderQuietlyAsync(builder.Provider, builder.BrowseName.Name).ConfigureAwait(false); } + if (organizedBy != null) + { + AutoIdReaderPlacement.RemoveOrganize(organizedBy, device); + } await RemoveUnregisteredDeviceAsync(parentNode, device).ConfigureAwait(false); throw; } } + /// + /// Runs the builder's identification delegate once, checks a component of a machine before + /// anything is registered, and creates the device of the provider's reader kind. + /// + /// The provider reports an unknown reader kind. + private async ValueTask<(AutoIdDeviceState Device, Action ApplyIdentification)> CreateDeviceOfKindAsync( + AutoIdReaderBuilder builder, + CancellationToken cancellationToken) + { + AutoIdReaderKind kind = builder.Provider.Kind; + ISystemContext context = SystemContext; + QualifiedName name = builder.BrowseName; + DeviceIdentificationData? nameplate = null; + if (builder.Identification != null) + { + nameplate = new DeviceIdentificationData(); + builder.Identification(nameplate); + } + NodeState? requestedParent = builder.Parent; + if (builder.Machine != null) + { + // Checked before anything is registered: a reader that cannot be a component of + // the machine is not created at all. + requestedParent = m_placement.ResolveComponents(builder.Machine, name); + AutoIdReaderPlacement.ValidateComponentNameplate(nameplate, name); + } + switch (kind) + { + case AutoIdReaderKind.Rfid: + return await CreateDeviceAsync( + builder, + requestedParent, + nameplate, + ObjectTypeIds.RfidReaderDeviceType, + parent => context.CreateInstanceOfRfidReaderDeviceType(parent, name), + cancellationToken).ConfigureAwait(false); + case AutoIdReaderKind.Ocr: + return await CreateDeviceAsync( + builder, + requestedParent, + nameplate, + ObjectTypeIds.OcrReaderDeviceType, + parent => context.CreateInstanceOfOcrReaderDeviceType(parent, name), + cancellationToken).ConfigureAwait(false); + case AutoIdReaderKind.Optical: + return await CreateDeviceAsync( + builder, + requestedParent, + nameplate, + ObjectTypeIds.OpticalReaderDeviceType, + parent => context.CreateInstanceOfOpticalReaderDeviceType(parent, name), + cancellationToken).ConfigureAwait(false); + case AutoIdReaderKind.OpticalVerifier: + return await CreateDeviceAsync( + builder, + requestedParent, + nameplate, + ObjectTypeIds.OpticalVerifierDeviceType, + parent => context.CreateInstanceOfOpticalVerifierDeviceType(parent, name), + cancellationToken).ConfigureAwait(false); + case AutoIdReaderKind.Rtls: + return await CreateDeviceAsync( + builder, + requestedParent, + nameplate, + ObjectTypeIds.RtlsDeviceType, + parent => context.CreateInstanceOfRtlsDeviceType(parent, name), + cancellationToken).ConfigureAwait(false); + default: + throw new ArgumentOutOfRangeException(nameof(builder), kind, "Unknown reader kind."); + } + } + + /// + /// Keeps a reader placed below another parent reachable from the DI DeviceSet + /// (OPC 10000-100 4.9): when DeviceSet does not reach the parent, it organizes the + /// reader directly. + /// + /// The DeviceSet that now organizes the reader, else . + /// The node manager publishes no DeviceSet. + private NodeState? KeepReachableFromDeviceSet(NodeState parent, AutoIdDeviceState device) + { + NodeState deviceSet = m_placement.FindDeviceSet() ?? + throw ServiceResultException.Create( + StatusCodes.BadConfigurationError, + "The node manager publishes no DeviceSet to locate the reader '{0}' from.", + device.BrowseName); + if (m_placement.IsReachableFrom(deviceSet, parent)) + { + return null; + } + AutoIdReaderPlacement.Organize(deviceSet, device); + return deviceSet; + } + private async ValueTask<(AutoIdDeviceState Device, Action ApplyIdentification)> CreateDeviceAsync( AutoIdReaderBuilder builder, + NodeState? parent, + DeviceIdentificationData? nameplate, ExpandedNodeId typeId, Func create, CancellationToken cancellationToken) @@ -416,26 +498,46 @@ namespace Opc.Ua.AutoID.Server Di.Server.Builders.IDeviceBuilder deviceBuilder = await Manager.CreateDeviceAsync( builder.BrowseName, typeDefinitionId, - parent => + deviceParent => { - TDevice device = create(parent); + TDevice device = create(deviceParent); AutoIdDeviceConfigurator.Configure(SystemContext, device, builder); return device; }, - builder.Parent, + parent, cancellationToken).ConfigureAwait(false); return ( deviceBuilder.Device, () => { - if (builder.Identification != null) + if (nameplate != null) { - deviceBuilder.WithIdentification(builder.Identification); + deviceBuilder.WithIdentification(data => CopyNameplate(nameplate, data)); } } ); } + /// + /// Copies the nameplate the builder's delegate produced once, so the delegate is not run + /// a second time. + /// + private static void CopyNameplate(DeviceIdentificationData source, DeviceIdentificationData target) + { + target.Manufacturer = source.Manufacturer; + target.ManufacturerUri = source.ManufacturerUri; + target.Model = source.Model; + target.HardwareRevision = source.HardwareRevision; + target.SoftwareRevision = source.SoftwareRevision; + target.DeviceRevision = source.DeviceRevision; + target.ProductCode = source.ProductCode; + target.DeviceManual = source.DeviceManual; + target.DeviceClass = source.DeviceClass; + target.SerialNumber = source.SerialNumber; + target.ProductInstanceUri = source.ProductInstanceUri; + target.RevisionCounter = source.RevisionCounter; + } + private async ValueTask DisposeProviderQuietlyAsync(IAutoIdReaderProvider provider, string? name) { try @@ -603,6 +705,7 @@ namespace Opc.Ua.AutoID.Server } private readonly ILogger m_logger; + private readonly AutoIdReaderPlacement m_placement; private readonly Lock m_lock = new(); private readonly List m_readers = []; private readonly SemaphoreSlim m_createGate = new(1, 1); diff --git a/src/Opc.Ua.AutoID.Server/Builders/AutoIdReaderBuilder.cs b/src/Opc.Ua.AutoID.Server/Builders/AutoIdReaderBuilder.cs index d5652ef4e0..5824a96764 100644 --- a/src/Opc.Ua.AutoID.Server/Builders/AutoIdReaderBuilder.cs +++ b/src/Opc.Ua.AutoID.Server/Builders/AutoIdReaderBuilder.cs @@ -56,6 +56,8 @@ namespace Opc.Ua.AutoID.Server.Builders internal NodeState? Parent { get; private set; } + internal BaseObjectState? Machine { get; private set; } + internal string? DeviceName { get; private set; } internal string? DeviceInfo { get; private set; } @@ -77,7 +79,32 @@ namespace Opc.Ua.AutoID.Server.Builders /// public IAutoIdReaderBuilder WithParent(NodeState parent) { - Parent = parent ?? throw new ArgumentNullException(nameof(parent)); + if (parent == null) + { + throw new ArgumentNullException(nameof(parent)); + } + if (Machine != null) + { + throw new InvalidOperationException( + "A reader that is a component of a machine is placed below its Components add-in."); + } + Parent = parent; + return this; + } + + /// + public IAutoIdReaderBuilder AsComponentOf(BaseObjectState machine) + { + if (machine == null) + { + throw new ArgumentNullException(nameof(machine)); + } + if (Parent != null) + { + throw new InvalidOperationException( + "A reader placed below an explicit parent cannot also be a component of a machine."); + } + Machine = machine; return this; } diff --git a/src/Opc.Ua.AutoID.Server/Builders/IAutoIdReaderBuilder.cs b/src/Opc.Ua.AutoID.Server/Builders/IAutoIdReaderBuilder.cs index 4aa42497d7..5eeea37def 100644 --- a/src/Opc.Ua.AutoID.Server/Builders/IAutoIdReaderBuilder.cs +++ b/src/Opc.Ua.AutoID.Server/Builders/IAutoIdReaderBuilder.cs @@ -51,11 +51,61 @@ namespace Opc.Ua.AutoID.Server.Builders public interface IAutoIdReaderBuilder { /// - /// Organizes the device below instead of the DI DeviceSet. + /// Places the device below (HasComponent) rather than + /// directly below the DI DeviceSet. /// + /// + /// + /// OPC 10000-100 4.9 requires every ComponentType instance to be reachable from + /// DeviceSet through hierarchical references, and only the root of a device composed + /// of devices to be referenced from DeviceSet itself. When DeviceSet already + /// reaches (for example another reader, or a device it + /// organizes), the reader is reached through it and gets no further reference. Otherwise + /// DeviceSet also organizes the reader (Organizes), so it stays a starting + /// point to locate the device. + /// + /// + /// To make the reader a component of a Machinery machine, use + /// instead; the two cannot be combined. + /// + /// /// The parent node, owned by the same node manager. + /// is null. + /// + /// was called on this builder. + /// IAutoIdReaderBuilder WithParent(NodeState parent); + /// + /// Makes the device an identifiable component of a machine (OPC 40001-1 11). + /// + /// + /// + /// Requires AutoID composed into the Machinery owner (AddAutoIdToMachinery, or a + /// Device Integration owner that loads the Machinery model). The machine must be a node + /// of that owner and publish the Components add-in, for example built with + /// IMachineBuilder.WithComponents. When the reader is built, it is placed below + /// Components (HasComponent) and gets the Identification add-in + /// (HasAddIn) typed MachineryComponentIdentificationType. The add-in owns + /// no copies of the nameplate: it references the reader's own DI nameplate properties + /// (Manufacturer, SerialNumber and those of the other members that are set). + /// Set the nameplate with ; Manufacturer and + /// SerialNumber are mandatory for a component and checked before anything is + /// registered. + /// + /// + /// The reader stays a device: DeviceSet organizes it unless it already reaches + /// the machine (OPC 10000-100 4.9, see ). It is not added to the + /// Machines folder. + /// + /// + /// The machine, a node of the Machinery owner. + /// is null. + /// + /// was called on this builder. + /// + IAutoIdReaderBuilder AsComponentOf(BaseObjectState machine); + /// /// Sets the DeviceName property (OPC 30010 6.1.3.14); defaults to the browse name. /// @@ -112,8 +162,9 @@ namespace Opc.Ua.AutoID.Server.Builders /// /// Transfers ownership of the provider to the reader: it is disposed with the reader when it - /// implements or . Without this the - /// caller owns the provider. + /// implements or , and also when + /// fails while creating the reader. Without this the caller owns + /// the provider. /// IAutoIdReaderBuilder OwnsProvider(); @@ -123,8 +174,10 @@ namespace Opc.Ua.AutoID.Server.Builders /// Cancels the operation. /// The live reader. /// - /// The name is already taken below the parent (Bad_BrowseNameDuplicated) or the - /// parent is not available (Bad_ConfigurationError). + /// The name is already taken below the parent (Bad_BrowseNameDuplicated), the + /// parent is not available, or the machine of cannot take the + /// reader as a component or the nameplate lacks a mandatory member + /// (Bad_ConfigurationError). /// /// The builder was already used. /// The runtime has been disposed. diff --git a/src/Opc.Ua.AutoID.Server/NugetREADME.md b/src/Opc.Ua.AutoID.Server/NugetREADME.md index faa971559d..98191c8d43 100644 --- a/src/Opc.Ua.AutoID.Server/NugetREADME.md +++ b/src/Opc.Ua.AutoID.Server/NugetREADME.md @@ -37,6 +37,14 @@ services.AddOpcUa() }); ``` +Every reader stays reachable from the DI `DeviceSet` (OPC 10000-100 4.9): +`WithParent` places it below another node, and `DeviceSet` organizes it as well +unless it already reaches that node. In the Machinery composition, +`AsComponentOf(machine)` makes a reader an identifiable component of a machine +(OPC 40001-1 11): it is referenced from the machine's `Components` add-in and +gets a `MachineryComponentIdentificationType` add-in that references its +nameplate. Readers are never added to the `Machines` folder. + ## Providers Implement `IAutoIdReaderProvider` and add interfaces for what the hardware can do; diff --git a/tests/Opc.Ua.AutoID.Tests/AutoIdCompositionTests.cs b/tests/Opc.Ua.AutoID.Tests/AutoIdCompositionTests.cs index df49de61d4..c721494f43 100644 --- a/tests/Opc.Ua.AutoID.Tests/AutoIdCompositionTests.cs +++ b/tests/Opc.Ua.AutoID.Tests/AutoIdCompositionTests.cs @@ -38,8 +38,10 @@ using NUnit.Framework; using Opc.Ua.AutoID.Server; using Opc.Ua.AutoID.Server.Hosting; using Opc.Ua.Di.Server; +using Opc.Ua.Di.Server.Builders; using Opc.Ua.Di.Server.Hosting; using Opc.Ua.Machinery.Server; +using Opc.Ua.Machinery.Server.Builders; using Opc.Ua.Pumps.Server; using Opc.Ua.Server; using Opc.Ua.Server.Hosting; @@ -333,6 +335,340 @@ namespace Opc.Ua.AutoID.Tests }); } + [Test] + public async Task AReaderBecomesAComponentOfAMachineAndStaysADeviceAsync() + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + IMachineHandle press = await AddPressAsync(fixture, withComponents: true); + + AutoIdReader reader = await runtime.Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(press.State) + .WithIdentification(nameplate => + { + nameplate.Manufacturer = new LocalizedText("Contoso"); + nameplate.SerialNumber = "R-1"; + nameplate.Model = new LocalizedText("Portal 100"); + nameplate.DeviceManual = "https://example.com/manual"; + }) + .BuildAsync(); + + ServerSystemContext context = fixture.Manager.SystemContext; + NodeState components = Components(fixture, press.State)!; + AutoIdDeviceState device = reader.Device; + var identification = (BaseObjectState)device.FindChild( + context, new QualifiedName(Di.BrowseNames.Identification, fixture.Manager.DiNamespaceIndex))!; + Assert.Multiple(() => + { + Assert.That(device.Parent, Is.SameAs(components), + "Components references the component (OPC 40001-1 11.2)"); + Assert.That(device.ReferenceTypeId, Is.EqualTo(Types.ReferenceTypeIds.HasComponent)); + Assert.That(fixture.DeviceSet.ReferenceExists( + Types.ReferenceTypeIds.Organizes, false, device.NodeId), Is.True, + "the reader stays reachable from DeviceSet (OPC 10000-100 4.9)"); + Assert.That(fixture.MachinesFolder.ReferenceExists( + Types.ReferenceTypeIds.Organizes, false, device.NodeId), Is.False, + "a component is not a machine"); + Assert.That(identification, Is.Not.Null); + Assert.That(identification.ReferenceTypeId, Is.EqualTo(Types.ReferenceTypeIds.HasAddIn)); + Assert.That(identification.TypeDefinitionId, Is.EqualTo(ExpandedNodeId.ToNodeId( + Machinery.ObjectTypeIds.MachineryComponentIdentificationType, context.NamespaceUris))); + Assert.That(identification.ReferenceExists( + Types.ReferenceTypeIds.HasProperty, false, device.Manufacturer!.NodeId), Is.True, + "the add-in references the nameplate instead of copying it"); + Assert.That(identification.ReferenceExists( + Types.ReferenceTypeIds.HasProperty, false, device.SerialNumber!.NodeId), Is.True); + Assert.That(identification.ReferenceExists( + Types.ReferenceTypeIds.HasProperty, false, device.Model!.NodeId), Is.True); + Assert.That(identification.ReferenceExists( + Types.ReferenceTypeIds.HasProperty, false, device.DeviceManual!.NodeId), Is.False, + "OPC 40001-1 10.2 does not recommend DeviceManual for components"); + Assert.That(device.ProductInstanceUri, Is.Null, "a component without a URI omits it"); + }); + AssertOwnedSubtree(fixture.Manager, device); + } + + [Test] + public async Task AComponentNeedsAMachineWithComponentsAndRegistersNothingOtherwiseAsync() + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + IMachineHandle press = await AddPressAsync(fixture, withComponents: false); + var provider = new FakeProvider(); + + ServiceResultException error = (await Assert.ThrowsAsync( + async () => await runtime + .Reader(fixture.Name("Infeed"), provider) + .AsComponentOf(press.State) + .WithIdentification(Nameplate) + .BuildAsync()))!; + AutoIdReader retried = await runtime.CreateReaderAsync(fixture.Name("Infeed"), new FakeProvider()); + + Assert.Multiple(() => + { + Assert.That(error.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError)); + Assert.That(error.Message, Does.Contain("WithComponents")); + Assert.That(provider.AttachCalls, Is.Zero, "nothing was created"); + Assert.That(runtime.Readers.ToArray(), Is.EqualTo(new[] { retried })); + Assert.That(retried.Device.Parent, Is.SameAs(fixture.DeviceSet)); + }); + } + + [Test] + public async Task AComponentNeedsTheMandatoryIdentificationAsync() + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + IMachineHandle press = await AddPressAsync(fixture, withComponents: true); + + ServiceResultException none = (await Assert.ThrowsAsync( + async () => await runtime + .Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(press.State) + .BuildAsync()))!; + ServiceResultException noSerial = (await Assert.ThrowsAsync( + async () => await runtime + .Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(press.State) + .WithIdentification(nameplate => nameplate.Manufacturer = new LocalizedText("Contoso")) + .BuildAsync()))!; + + Assert.Multiple(() => + { + Assert.That(none.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError)); + Assert.That(none.Message, Does.Contain("Manufacturer and SerialNumber")); + Assert.That(noSerial.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError)); + Assert.That(noSerial.Message, Does.Contain("SerialNumber").And.Not.Contain("Manufacturer and")); + Assert.That(runtime.Readers.Count, Is.Zero); + Assert.That(Components(fixture, press.State)!.FindChild( + fixture.Manager.SystemContext, fixture.Name("Infeed")), Is.Null); + }); + } + + [Test] + public async Task AComponentNeedsAMachineOfTheOwnerAsync() + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + var foreign = new BaseObjectState(null) + { + BrowseName = fixture.Name("Foreign"), + NodeId = new NodeId("Foreign", fixture.Manager.MachineryInstanceNamespaceIndex) + }; + + ServiceResultException error = (await Assert.ThrowsAsync( + async () => await runtime + .Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(foreign) + .WithIdentification(Nameplate) + .BuildAsync()))!; + + Assert.Multiple(() => + { + Assert.That(error.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError)); + Assert.That(error.Message, Does.Contain("not a node of the AutoID owner")); + Assert.That(runtime.Readers.Count, Is.Zero); + }); + } + + [Test] + public async Task AFailedComponentCreationIsRolledBackAsync() + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + IMachineHandle press = await AddPressAsync(fixture, withComponents: true); + NodeState components = Components(fixture, press.State)!; + ServerSystemContext context = fixture.Manager.SystemContext; + int deviceSetReferences = CountReferences(context, fixture.DeviceSet); + runtime.ReaderRegisteredForTest = _ => throw new InvalidOperationException("injected"); + + await Assert.ThrowsAsync(async () => await runtime + .Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(press.State) + .WithIdentification(Nameplate) + .BuildAsync()); + int afterFailure = CountReferences(context, fixture.DeviceSet); + NodeState? leftOver = components.FindChild(context, fixture.Name("Infeed")); + runtime.ReaderRegisteredForTest = null; + AutoIdReader reader = await runtime.Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(press.State) + .WithIdentification(Nameplate) + .BuildAsync(); + + Assert.Multiple(() => + { + Assert.That(afterFailure, Is.EqualTo(deviceSetReferences), "the DeviceSet reference was removed"); + Assert.That(leftOver, Is.Null, "the component was removed from Components"); + Assert.That(runtime.Readers.ToArray(), Is.EqualTo(new[] { reader })); + Assert.That(fixture.Manager.FindPredefinedNode(reader.NodeId), Is.SameAs(reader.Device)); + }); + } + + [TestCase(false)] + [TestCase(true)] + public async Task AReaderBecomesAComponentOfABoundCompanionComponentsAddInAsync(bool nested) + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + IMachineryBuildContext build = fixture.Manager.CreateMachineryBuildContext(); + IMachineBuilder press = build.AddMachine(fixture.Name("Press1")) + .WithIdentification(identification => + { + identification.Manufacturer = new LocalizedText("Contoso"); + identification.SerialNumber = "PR-1"; + identification.ProductInstanceUri = "urn:tests:autoid:press:1"; + }); + NodeState container = press.State; + if (nested) + { + BaseObjectState assembly = build.Context.CreateInstanceOfBaseObjectType( + press.State, fixture.Name("Assembly")); + assembly.ReferenceTypeId = Types.ReferenceTypeIds.HasComponent; + press.State.AddChild(assembly); + container = assembly; + } + // Named in the companion's namespace, as a companion model declares it. + var companionName = new QualifiedName( + Machinery.BrowseNames.Components, + (ushort)build.Context.NamespaceUris.GetIndex(AutoIdModel.NamespaceUri)); + Machinery.MachineComponentsState bound = Machinery.OpcUaMachineryExtensions + .CreateInstanceOfMachineComponentsType(build.Context, container, companionName); + bound.ReferenceTypeId = Types.ReferenceTypeIds.HasAddIn; + container.AddChild(bound); + await press.WithComponents(bound, _ => { }).BuildAsync(); + + AutoIdReader reader = await runtime.Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(press.State) + .WithIdentification(Nameplate) + .BuildAsync(); + + Assert.Multiple(() => + { + Assert.That(bound.Parent, Is.SameAs(container), "the binding keeps its parent"); + Assert.That(bound.BrowseName, Is.EqualTo(companionName), "the binding keeps its name"); + Assert.That(reader.Device.Parent, Is.SameAs(bound)); + Assert.That(fixture.DeviceSet.ReferenceExists( + Types.ReferenceTypeIds.Organizes, false, reader.NodeId), Is.True); + Assert.That(reader.Device.FindChild( + fixture.Manager.SystemContext, + new QualifiedName(Di.BrowseNames.Identification, fixture.Manager.DiNamespaceIndex)), Is.Not.Null); + }); + } + + [Test] + public async Task AMachineWithoutBuildingBlocksOffersItsOnlyComponentsChildAsync() + { + await using var fixture = new ComposedOwner(); + await fixture.StartAsync(); + AutoIdRuntime runtime = fixture.Manager.CreateAutoIdRuntime(); + BaseObjectState cell = await AddBareMachineAsync(fixture, "Cell", "Components"); + BaseObjectState line = await AddBareMachineAsync(fixture, "Line", "Components", "MoreComponents"); + + AutoIdReader reader = await runtime.Reader(fixture.Name("Infeed"), new FakeProvider()) + .AsComponentOf(cell) + .WithIdentification(Nameplate) + .BuildAsync(); + var provider = new FakeProvider(); + ServiceResultException ambiguous = (await Assert.ThrowsAsync( + async () => await runtime + .Reader(fixture.Name("Outfeed"), provider) + .AsComponentOf(line) + .WithIdentification(Nameplate) + .BuildAsync()))!; + + Assert.Multiple(() => + { + Assert.That(((BaseInstanceState)reader.Device.Parent!).Parent, Is.SameAs(cell)); + Assert.That(ambiguous.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError)); + Assert.That(ambiguous.Message, Does.Contain("more than one Components add-in")); + Assert.That(provider.AttachCalls, Is.Zero); + Assert.That(runtime.Readers.ToArray(), Is.EqualTo(new[] { reader })); + }); + } + + /// + /// Registers a machine built without the Machinery builders: no MachineryBuildingBlocks + /// folder, and MachineComponentsType children named in the companion's namespace. + /// + private static async Task AddBareMachineAsync( + ComposedOwner fixture, + string name, + params string[] componentsNames) + { + ServerSystemContext context = fixture.Manager.SystemContext; + ushort companion = (ushort)context.NamespaceUris.GetIndex(AutoIdModel.NamespaceUri); + var machine = new BaseObjectState(null) + { + SymbolicName = name, + BrowseName = fixture.Name(name), + DisplayName = new LocalizedText(name), + TypeDefinitionId = Ua.ObjectTypeIds.BaseObjectType, + NodeId = new NodeId(name, fixture.Manager.MachineryInstanceNamespaceIndex) + }; + foreach (string componentsName in componentsNames) + { + machine.AddChild(new BaseObjectState(machine) + { + SymbolicName = componentsName, + BrowseName = new QualifiedName(componentsName, companion), + DisplayName = new LocalizedText(componentsName), + TypeDefinitionId = ExpandedNodeId.ToNodeId( + Machinery.ObjectTypeIds.MachineComponentsType, + context.NamespaceUris), + ReferenceTypeId = Types.ReferenceTypeIds.HasAddIn, + NodeId = new NodeId(name + "." + componentsName, fixture.Manager.MachineryInstanceNamespaceIndex) + }); + } + await fixture.Manager.AddPredefinedNodeAsync(machine); + return machine; + } + + private static void Nameplate(DeviceIdentificationData nameplate) + { + nameplate.Manufacturer = new LocalizedText("Contoso"); + nameplate.SerialNumber = "R-1"; + } + + private static async Task> AddPressAsync( + ComposedOwner fixture, + bool withComponents) + { + IMachineryBuildContext context = fixture.Manager.CreateMachineryBuildContext(); + IMachineBuilder press = context.AddMachine(fixture.Name("Press1")) + .WithIdentification(identification => + { + identification.Manufacturer = new LocalizedText("Contoso"); + identification.SerialNumber = "PR-1"; + identification.ProductInstanceUri = "urn:tests:autoid:press:1"; + }); + if (withComponents) + { + press = press.WithComponents(_ => { }); + } + return await press.BuildAsync(); + } + + private static BaseInstanceState? Components(ComposedOwner fixture, NodeState machine) + { + ServerSystemContext context = fixture.Manager.SystemContext; + return machine.FindChild(context, new QualifiedName( + Machinery.BrowseNames.Components, + (ushort)context.NamespaceUris.GetIndex(Machinery.Namespaces.Machinery))); + } + + private static int CountReferences(ISystemContext context, NodeState node) + { + var references = new List(); + node.GetReferences(context, references); + return references.Count; + } + /// /// Checks that the reader and its children are registered in the owner with NodeIds in the /// Machinery instance namespace. @@ -413,6 +749,12 @@ namespace Opc.Ua.AutoID.Tests public NodeState DeviceSet => Manager.FindPredefinedNode( ExpandedNodeId.ToNodeId(Di.ObjectIds.DeviceSet, Manager.SystemContext.NamespaceUris))!; + /// + /// Gets the owner's Machinery Machines folder. + /// + public NodeState MachinesFolder => Manager.FindPredefinedNode( + ExpandedNodeId.ToNodeId(Machinery.ObjectIds.Machines, Manager.SystemContext.NamespaceUris))!; + /// /// Starts the server and creates the owner's address space. /// diff --git a/tests/Opc.Ua.AutoID.Tests/AutoIdPlacementEndToEndTests.cs b/tests/Opc.Ua.AutoID.Tests/AutoIdPlacementEndToEndTests.cs new file mode 100644 index 0000000000..dc46062b69 --- /dev/null +++ b/tests/Opc.Ua.AutoID.Tests/AutoIdPlacementEndToEndTests.cs @@ -0,0 +1,320 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 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.Globalization; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using NUnit.Framework; +using Opc.Ua.AutoID.Client; +using Opc.Ua.AutoID.Server; +using Opc.Ua.Client; +using Opc.Ua.Machinery.Client; +using Opc.Ua.Machinery.Server; +using Opc.Ua.Machinery.Server.Builders; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; +using Opc.Ua.Tests; + +namespace Opc.Ua.AutoID.Tests +{ + /// + /// Reader placement in a hosted Machinery and AutoID server, read back over a real session: + /// every reader stays reachable from the DI DeviceSet (OPC 10000-100 4.9), a reader can + /// be an identifiable component of a machine (OPC 40001-1 11) and no reader becomes a machine. + /// + [TestFixture] + [Category("AutoID")] + [Category("Composition")] + [NonParallelizable] + public sealed class AutoIdPlacementEndToEndTests + { + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + var configured = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var services = new ServiceCollection(); + services.AddLogging(); + services.AddOpcUa() + .AddServer(ConfigureServer) + .AddMachinery(options => + { + options.Parts = MachineryParts.BuildingBlocks; + options.InstanceNamespaceUri = InstanceNamespaceUri; + }) + .AddAutoIdToMachinery() + .ConfigureMachinery(async (machinery, ct) => + m_press = await machinery + .AddMachine(new QualifiedName(PressName, machinery.InstanceNamespaceIndex)) + .WithIdentification(identification => + { + identification.Manufacturer = new LocalizedText("Contoso"); + identification.SerialNumber = "PR-1"; + identification.ProductInstanceUri = "urn:tests:autoid:placement:press:1"; + }) + .WithComponents(_ => { }) + .BuildAsync(ct) + .ConfigureAwait(false)) + .ConfigureAutoIdFor(async (context, ct) => + { + QualifiedName Name(string name) => new(name, context.Runtime.InstanceNamespaceIndex); + m_gate = await context.Runtime.CreateReaderAsync(Name(GateName), m_gateProvider, ct) + .ConfigureAwait(false); + m_antenna = await context.Runtime.Reader(Name(AntennaName), new FakeProvider()) + .WithParent(m_gate.Device) + .BuildAsync(ct) + .ConfigureAwait(false); + m_infeed = await context.Runtime.Reader(Name(InfeedName), m_infeedProvider) + .AsComponentOf(m_press!.State) + .WithIdentification(nameplate => + { + nameplate.Manufacturer = new LocalizedText("Contoso"); + nameplate.SerialNumber = "R-100"; + nameplate.ProductCode = "PORTAL-100"; + }) + .BuildAsync(ct) + .ConfigureAwait(false); + m_outfeed = await context.Runtime.Reader(Name(OutfeedName), new FakeProvider()) + .WithParent(m_press.State) + .BuildAsync(ct) + .ConfigureAwait(false); + configured.TrySetResult(true); + }); + + m_provider = services.BuildServiceProvider(); + m_host = m_provider.GetServices().Single(); + await m_host.StartAsync(CancellationToken.None).ConfigureAwait(false); + for (int ii = 0; ii < 300 && !configured.Task.IsCompleted; ii++) + { + if (m_host is BackgroundService { ExecuteTask: { IsFaulted: true } startup }) + { + await startup.ConfigureAwait(false); + } + await Task.Delay(100).ConfigureAwait(false); + } + Assert.That(configured.Task.IsCompleted, Is.True, "the companion configurators completed"); + m_session = await AutoIdHostFixture.ConnectAsync(m_endpointUrl, "AutoIdPlacement").ConfigureAwait(false); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session != null) + { + await m_session.DisposeAsync().ConfigureAwait(false); + } + if (m_host != null) + { + await m_host.StopAsync(CancellationToken.None).ConfigureAwait(false); + } + if (m_provider != null) + { + await m_provider.DisposeAsync().ConfigureAwait(false); + } + m_gateProvider.Dispose(); + m_infeedProvider.Dispose(); + } + + [Test] + public async Task TheDeviceSetReachesEveryReaderAndOrganizesOnlyTheRootsAsync() + { + NodeId deviceSet = ExpandedNodeId.ToNodeId(Di.ObjectIds.DeviceSet, m_session!.NamespaceUris); + + string[] devices = await BrowseNamesAsync(deviceSet).ConfigureAwait(false); + string[] belowGate = await BrowseNamesAsync(m_gate!.NodeId).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(devices, Does.Contain(GateName), "a default reader"); + Assert.That(devices, Does.Contain(InfeedName), "a component of a machine stays a device"); + Assert.That(devices, Does.Contain(OutfeedName), "DeviceSet does not reach the machine"); + Assert.That(devices, Does.Not.Contain(AntennaName), "only the root of a composed device"); + Assert.That(belowGate, Does.Contain(AntennaName), "the composed device reaches its component"); + Assert.That(m_antenna!.Device.Parent, Is.SameAs(m_gate.Device)); + Assert.That(m_outfeed!.Device.Parent, Is.SameAs(m_press!.State)); + }); + } + + [Test] + public async Task NoReaderBecomesAMachineAsync() + { + var machinery = new MachineryClient(m_session!, NUnitTelemetryContext.Create()); + NodeId machinesFolder = ExpandedNodeId.ToNodeId(Machinery.ObjectIds.Machines, m_session!.NamespaceUris); + + NodeId[] machines = (await machinery.DiscoverMachinesAsync().ConfigureAwait(false)).ToArray()!; + string[] machineNames = await BrowseNamesAsync(machinesFolder).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(machines, Is.EqualTo(new[] { m_press!.NodeId })); + Assert.That(machineNames, Does.Not.Contain(InfeedName).And.Not.Contain(OutfeedName) + .And.Not.Contain(GateName).And.Not.Contain(AntennaName)); + }); + } + + [Test] + public async Task TheMachineListsTheReaderAsAnIdentifiableComponentAsync() + { + var machinery = new MachineryClient(m_session!, NUnitTelemetryContext.Create()); + + var components = new List(); + await foreach (MachineEntry entry in machinery.EnumerateComponentsAsync(m_press!.NodeId)) + { + components.Add(entry); + } + NodeId identification = await machinery.ResolveIdentificationAsync(m_infeed!.NodeId).ConfigureAwait(false); + MachineIdentification? nameplate = await machinery.ReadIdentificationAsync(m_infeed.NodeId) + .ConfigureAwait(false); + BrowseResult addIn = await BrowseAsync( + m_infeed.NodeId, Types.ReferenceTypeIds.HasAddIn, includeSubtypes: false).ConfigureAwait(false); + BrowseResult properties = await BrowseAsync( + identification, Types.ReferenceTypeIds.HasProperty, includeSubtypes: false) + .ConfigureAwait(false); + NodeId[] referenced = [.. properties.References.ToArray()! + .Select(reference => ExpandedNodeId.ToNodeId(reference.NodeId, m_session!.NamespaceUris))]; + + Assert.Multiple(() => + { + Assert.That(components.Select(entry => entry.NodeId), Is.EqualTo(new[] { m_infeed.NodeId }), + "only the reader made a component is listed, not the one placed below the machine"); + Assert.That(identification.IsNull, Is.False); + Assert.That(addIn.References.ToArray()!.Select(reference => reference.BrowseName.Name), + Does.Contain("Identification"), "the add-in is referenced with HasAddIn"); + Assert.That(ExpandedNodeId.ToNodeId( + addIn.References.ToArray()!.Single(reference => reference.BrowseName.Name == "Identification") + .TypeDefinition, + m_session!.NamespaceUris), + Is.EqualTo(ExpandedNodeId.ToNodeId( + Machinery.ObjectTypeIds.MachineryComponentIdentificationType, m_session.NamespaceUris))); + Assert.That(nameplate, Is.Not.Null); + Assert.That(nameplate!.Manufacturer.Text, Is.EqualTo("Contoso")); + Assert.That(nameplate.SerialNumber, Is.EqualTo("R-100")); + Assert.That(nameplate.ProductCode, Is.EqualTo("PORTAL-100")); + Assert.That(referenced, Does.Contain(m_infeed.Device.Manufacturer!.NodeId) + .And.Contain(m_infeed.Device.SerialNumber!.NodeId), + "the add-in references the reader's nameplate instead of copying it"); + }); + } + + [Test] + public async Task TheAutoIdClientFindsEveryReaderInTheDeviceSetAsync() + { + var client = new AutoIdClient(m_session!, NUnitTelemetryContext.Create()); + + var found = new List(); + await foreach (AutoIdReaderEntry entry in client.EnumerateReadersAsync()) + { + found.Add(entry); + } + AutoIdDeviceSnapshot infeed = await client.ReadDeviceAsync( + found.Single(entry => entry.BrowseName.Name == InfeedName)).ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(found.Select(entry => entry.BrowseName.Name), + Is.EquivalentTo(new[] { GateName, InfeedName, OutfeedName })); + Assert.That(infeed.SerialNumber, Is.EqualTo("R-100")); + }); + } + + private async Task BrowseNamesAsync(NodeId root) + { + BrowseResult result = await BrowseAsync( + root, ReferenceTypeIds.HierarchicalReferences, includeSubtypes: true).ConfigureAwait(false); + return [.. result.References.ToArray()!.Select(reference => reference.BrowseName.Name!)]; + } + + private async Task BrowseAsync(NodeId root, NodeId referenceTypeId, bool includeSubtypes) + { + BrowseResponse response = await m_session!.BrowseAsync( + null, + null, + 0, + [ + new BrowseDescription + { + NodeId = root, + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = referenceTypeId, + IncludeSubtypes = includeSubtypes, + ResultMask = (uint)BrowseResultMask.All + } + ], + CancellationToken.None).ConfigureAwait(false); + BrowseResult result = response.Results[0]; + Assert.That(result.StatusCode, Is.EqualTo((StatusCode)StatusCodes.Good)); + Assert.That(result.ContinuationPoint.IsNull || result.ContinuationPoint.IsEmpty, Is.True); + return result; + } + + private void ConfigureServer(OpcUaServerOptions options) + { + const string applicationName = "AutoIdPlacement"; + string testRoot = System.IO.Path.Combine( + TestContext.CurrentContext.WorkDirectory, + "autoid-placement", + Guid.NewGuid().ToString("N")[..8]); + options.ApplicationName = applicationName; + options.ApplicationUri = "urn:localhost:" + applicationName; + options.ProductUri = "urn:localhost:" + applicationName + ":product"; + options.PkiRoot = System.IO.Path.Combine(testRoot, "pki"); + options.AutoAcceptUntrustedCertificates = true; + options.IncludeUnsecurePolicyNone = true; + options.EndpointUrls.Clear(); + m_endpointUrl = "opc.tcp://localhost:" + + AutoIdHostFixture.GetAvailablePort().ToString(CultureInfo.InvariantCulture) + + "/" + + applicationName; + options.EndpointUrls.Add(m_endpointUrl); + } + + private const string InstanceNamespaceUri = "urn:tests:autoid:placement:instances"; + private const string PressName = "Press1"; + private const string GateName = "Gate1"; + private const string AntennaName = "Gate1Antenna"; + private const string InfeedName = "Infeed"; + private const string OutfeedName = "Outfeed"; + + private readonly InMemoryRfidReaderProvider m_gateProvider = new(TimeSpan.FromMilliseconds(5)); + private readonly InMemoryRfidReaderProvider m_infeedProvider = new(TimeSpan.FromMilliseconds(5)); + private ServiceProvider? m_provider; + private IHostedService? m_host; + private ManagedSession? m_session; + private string m_endpointUrl = string.Empty; + private IMachineHandle? m_press; + private AutoIdReader? m_gate; + private AutoIdReader? m_antenna; + private AutoIdReader? m_infeed; + private AutoIdReader? m_outfeed; + } +} diff --git a/tests/Opc.Ua.AutoID.Tests/AutoIdReaderLifecycleTests.cs b/tests/Opc.Ua.AutoID.Tests/AutoIdReaderLifecycleTests.cs index e61b8c575c..9dca06a240 100644 --- a/tests/Opc.Ua.AutoID.Tests/AutoIdReaderLifecycleTests.cs +++ b/tests/Opc.Ua.AutoID.Tests/AutoIdReaderLifecycleTests.cs @@ -287,6 +287,11 @@ namespace Opc.Ua.AutoID.Tests new EUInformation(), new EUInformation(), new EUInformation(), new EUInformation())); IAutoIdReaderBuilder builder = runtime.Reader(m_fixture.Name(Unique("X")), rfid); Assert.Throws(() => builder.WithParent(null!)); + Assert.Throws(() => builder.AsComponentOf(null!)); + Assert.Throws(() => runtime.Reader(m_fixture.Name(Unique("X")), rfid) + .WithParent(new BaseObjectState(null)).AsComponentOf(new BaseObjectState(null))); + Assert.Throws(() => runtime.Reader(m_fixture.Name(Unique("X")), rfid) + .AsComponentOf(new BaseObjectState(null)).WithParent(new BaseObjectState(null))); Assert.Throws(() => builder.WithDeviceName(string.Empty)); Assert.Throws(() => builder.WithDeviceInfo(null!)); Assert.Throws(() => builder.WithDeviceLocation(null!)); @@ -320,9 +325,193 @@ namespace Opc.Ua.AutoID.Tests { Assert.That(inner.Device.Parent, Is.SameAs(outer.Device)); Assert.That(deviceSet.FindChild(m_fixture.Context, m_fixture.Name(Unique("Inner"))), Is.Null); + Assert.That(OrganizedFrom(deviceSet, inner.Device), Is.False, + "DeviceSet reaches the reader through its parent, the root (OPC 10000-100 4.9)"); }); } + [Test] + public async Task AReaderBelowAParentOutsideTheDeviceSetIsStillOrganizedByItAsync() + { + NodeState deviceSet = m_fixture!.Manager.FindPredefinedNode( + NodeId.Create(Di.Objects.DeviceSet, Di.Namespaces.OpcUaDi, m_fixture.Context.NamespaceUris))!; + var line = new FolderState(null) + { + SymbolicName = Unique("Line"), + BrowseName = m_fixture.Name(Unique("Line")), + DisplayName = new LocalizedText(Unique("Line")), + TypeDefinitionId = Ua.ObjectTypeIds.FolderType, + NodeId = new NodeId(Unique("Line"), m_fixture.Manager.InstanceNamespaceIndex) + }; + await m_fixture.Manager.AddPredefinedNodeAsync(line).ConfigureAwait(false); + + AutoIdReader reader = await m_fixture.Runtime.Reader(m_fixture.Name(Unique("Portal")), new FakeProvider()) + .WithParent(line).BuildAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(reader.Device.Parent, Is.SameAs(line)); + Assert.That(OrganizedFrom(deviceSet, reader.Device), Is.True, + "every ComponentType instance stays reachable from DeviceSet (OPC 10000-100 4.9)"); + Assert.That(reader.Device.ReferenceExists( + Types.ReferenceTypeIds.Organizes, true, deviceSet.NodeId), Is.True); + }); + } + + [TestCase(false)] + [TestCase(true)] + public async Task AReaderBelowANonHierarchicalChildOfAReachableDeviceIsOrganizedAsync(bool belowIt) + { + NodeState deviceSet = m_fixture!.Manager.FindPredefinedNode( + NodeId.Create(Di.Objects.DeviceSet, Di.Namespaces.OpcUaDi, m_fixture.Context.NamespaceUris))!; + AutoIdReader gate = await m_fixture.Runtime.CreateReaderAsync( + m_fixture.Name(Unique("Gate")), new FakeProvider()).ConfigureAwait(false); + // Attached the way a condition is: a child of the reader by HasCondition only. + var condition = new BaseObjectState(gate.Device) + { + SymbolicName = Unique("Condition"), + BrowseName = m_fixture.Name(Unique("Condition")), + DisplayName = new LocalizedText(Unique("Condition")), + TypeDefinitionId = Ua.ObjectTypeIds.BaseObjectType, + ReferenceTypeId = Types.ReferenceTypeIds.HasCondition, + NodeId = new NodeId(Unique("Condition"), m_fixture.Manager.InstanceNamespaceIndex) + }; + gate.Device.AddChild(condition); + NodeState parent = condition; + if (belowIt) + { + var folder = new FolderState(condition) + { + SymbolicName = Unique("Folder"), + BrowseName = m_fixture.Name(Unique("Folder")), + DisplayName = new LocalizedText(Unique("Folder")), + TypeDefinitionId = Ua.ObjectTypeIds.FolderType, + ReferenceTypeId = Types.ReferenceTypeIds.HasComponent, + NodeId = new NodeId(Unique("Folder"), m_fixture.Manager.InstanceNamespaceIndex) + }; + condition.AddChild(folder); + parent = folder; + } + await m_fixture.Manager.AddPredefinedNodeAsync(condition).ConfigureAwait(false); + + AutoIdReader reader = await m_fixture.Runtime.Reader(m_fixture.Name(Unique("Portal")), new FakeProvider()) + .WithParent(parent).BuildAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(reader.Device.Parent, Is.SameAs(parent)); + Assert.That(OrganizedFrom(deviceSet, reader.Device), Is.True, + "HasCondition is no hierarchical path from DeviceSet (OPC 10000-100 4.9)"); + }); + } + + [Test] + public async Task AFailedCreationBelowAnOutsideParentLeavesNoDeviceSetReferenceAsync() + { + await using AutoIdManagerFixture own = await StartOwnFixtureAsync().ConfigureAwait(false); + NodeState deviceSet = own.Manager.FindPredefinedNode( + NodeId.Create(Di.Objects.DeviceSet, Di.Namespaces.OpcUaDi, own.Context.NamespaceUris))!; + var line = new FolderState(null) + { + SymbolicName = "Line", + BrowseName = own.Name("Line"), + DisplayName = new LocalizedText("Line"), + TypeDefinitionId = Ua.ObjectTypeIds.FolderType, + NodeId = new NodeId("Line", own.Manager.InstanceNamespaceIndex) + }; + await own.Manager.AddPredefinedNodeAsync(line).ConfigureAwait(false); + int before = References(own.Context, deviceSet); + own.Runtime.ReaderRegisteredForTest = _ => throw new InvalidOperationException("injected"); + + await Assert.ThrowsAsync(async () => await own.Runtime + .Reader(own.Name("Portal"), new FakeProvider()).WithParent(line).BuildAsync().ConfigureAwait(false)); + own.Runtime.ReaderRegisteredForTest = null; + AutoIdReader retried = await own.Runtime.Reader(own.Name("Portal"), new FakeProvider()) + .WithParent(line).BuildAsync().ConfigureAwait(false); + + Assert.Multiple(() => + { + Assert.That(References(own.Context, deviceSet), Is.EqualTo(before + 1), + "only the retried reader is organized"); + Assert.That(OrganizedFrom(deviceSet, retried.Device), Is.True); + }); + } + + [Test] + public async Task AStandaloneReaderCannotBeAComponentOfAMachineAsync() + { + var machine = new BaseObjectState(null) + { + BrowseName = m_fixture!.Name(Unique("Press")), + NodeId = new NodeId(Unique("Press"), m_fixture.Manager.InstanceNamespaceIndex) + }; + + ServiceResultException error = (await Assert.ThrowsAsync( + async () => await m_fixture + .Runtime.Reader(m_fixture.Name(Unique("Component")), new FakeProvider()) + .AsComponentOf(machine) + .WithIdentification(nameplate => + { + nameplate.Manufacturer = new LocalizedText("Contoso"); + nameplate.SerialNumber = "R-1"; + }) + .BuildAsync().ConfigureAwait(false)))!; + + Assert.Multiple(() => + { + Assert.That(error.StatusCode, Is.EqualTo((StatusCode)StatusCodes.BadConfigurationError)); + Assert.That(error.Message, Does.Contain("AddAutoIdToMachinery")); + Assert.That(m_fixture.Runtime.Readers.ToArray()!.Select(reader => reader.Name.Name), + Does.Not.Contain(Unique("Component"))); + }); + } + + [TestCase("Identification")] + [TestCase("Component")] + [TestCase("DuplicateName")] + public async Task AFailedBuildDisposesAnOwnedProviderNoReaderTookOverAsync(string failure) + { + await using AutoIdManagerFixture own = await StartOwnFixtureAsync().ConfigureAwait(false); + if (failure == "DuplicateName") + { + await own.Runtime.CreateReaderAsync(own.Name("Dock"), new FakeProvider()).ConfigureAwait(false); + } + var provider = new FakeProvider(); + IAutoIdReaderBuilder builder = own.Runtime.Reader(own.Name("Dock"), provider).OwnsProvider(); + if (failure == "Identification") + { + builder.WithIdentification(_ => throw new InvalidOperationException("injected")); + } + else if (failure == "Component") + { + builder.AsComponentOf(new BaseObjectState(null) + { + BrowseName = own.Name("Press"), + NodeId = new NodeId("Press", own.Manager.InstanceNamespaceIndex) + }); + } + int readersBefore = own.Runtime.Readers.Count; + + Exception error = (await Assert.CatchAsync( + async () => await builder.BuildAsync().ConfigureAwait(false)))!; + + Assert.Multiple(() => + { + Assert.That(error, failure == "Identification" + ? Is.InstanceOf() + : Is.InstanceOf()); + Assert.That(provider.Disposed, Is.True, "the build owned the provider and no reader took it"); + Assert.That(provider.AttachCalls, Is.Zero); + Assert.That(own.Runtime.Readers.Count, Is.EqualTo(readersBefore), "nothing was registered"); + }); + if (failure != "DuplicateName") + { + AutoIdReader retried = await own.Runtime.CreateReaderAsync(own.Name("Dock"), new FakeProvider()) + .ConfigureAwait(false); + Assert.That(retried.Name, Is.EqualTo(own.Name("Dock")), "the failed build left the name free"); + } + } + [TestCase(false)] [TestCase(true)] public async Task DisposingTheRuntimeDetachesProvidersAndDisposesOnlyOwnedOnesAsync(bool owned) @@ -466,6 +655,18 @@ namespace Opc.Ua.AutoID.Tests return TestContext.CurrentContext.Test.Name + "_" + name; } + private static bool OrganizedFrom(NodeState deviceSet, NodeState device) + { + return deviceSet.ReferenceExists(Types.ReferenceTypeIds.Organizes, false, device.NodeId); + } + + private static int References(ISystemContext context, NodeState node) + { + var references = new System.Collections.Generic.List(); + node.GetReferences(context, references); + return references.Count; + } + private static async Task StartOwnFixtureAsync() { var own = new AutoIdManagerFixture();