mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-24 11:01:55 +08:00
AutoID: keep every reader reachable from DeviceSet, and let a reader be a component of a machine (#4853)
# Description Follow-up to the AutoID companion (#4825). Two changes to where the AutoID server places its readers: 1. **`IAutoIdReaderBuilder.WithParent` is made conformant with OPC 10000-100 4.9.** Until now it placed a reader below another node *instead of* `DeviceSet`, and the docs suggested "a component of a machine" as the use case. A machine in the Machinery `Machines` folder is not reachable from `DeviceSet`, so such a reader could not be located from `DeviceSet`, which DI 4.9 requires for every `ComponentType` instance (every AutoID device type derives from DI `DeviceType`). Now the reader is still placed below the parent (`HasComponent`, unchanged), and: - when `DeviceSet` already reaches the parent through hierarchical references (for example another reader, or any device it organizes), nothing else is added: the reader is a component of a composed device and DI 4.9 wants "only the root instance" referenced from `DeviceSet`; - otherwise `DeviceSet` also organizes the reader (`Organizes`, both directions), so it stays locatable. 2. **New opt-in `IAutoIdReaderBuilder.AsComponentOf(BaseObjectState machine)`**, available only when AutoID is composed with Machinery (`AddAutoIdToMachinery`, or a DI owner that loads the Machinery model). It makes the reader an identifiable component of the machine per OPC 40001-1 11: - the reader is placed below the machine's `Components` add-in (`HasComponent`, as `MachineComponentsType` declares for `<Component>`); - it gets the `2:Identification` add-in (`HasAddIn`) typed `MachineryComponentIdentificationType`, which holds no copies: it references the reader's own DI nameplate properties with `HasProperty` (`Manufacturer`, `SerialNumber`, and those of `Model`, `ManufacturerUri`, `ProductCode`, `HardwareRevision`, `SoftwareRevision`, `DeviceRevision`, `DeviceClass`, `ProductInstanceUri` that are set, plus `AssetId`/`ComponentName` when published; `DeviceManual` and `RevisionCounter` are left out as 40001-1 10.2 recommends); - the reader stays a device: the same `DeviceSet` rule as for `WithParent` applies with `Components` as the parent, so in the usual case (a machine in `Machines` that `DeviceSet` does not reach) `DeviceSet` organizes it directly. It is not added to `Machines`. - Checked before anything is registered, each failing with `Bad_ConfigurationError` and a message naming the way out: the server has no Machinery model (names `AddAutoIdToMachinery`), the machine is not a node of the AutoID owner, the machine has no `Components` add-in (names `WithComponents`), or the reader nameplate lacks `Manufacturer` and/or `SerialNumber` (mandatory in `MachineryItemIdentificationType`). `WithParent` and `AsComponentOf` exclude each other (`InvalidOperationException`). Default behaviour is unchanged: a reader built without `WithParent`/`AsComponentOf` is organized by `DeviceSet` and not added to `Machines`. Rollback on failure covers the new references: a failed build removes the `DeviceSet` reference, the component and its add-in, so the name can be used again. ## Specification basis | Statement | 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. For complex Devices that are composed of various components that are also Devices, only the root instance shall be referenced from the DeviceSet Object. The components of such complex Devices shall be locatable by following Hierarchical References from the root instance." | OPC 10000-100 (DI) 1.05.0, 4.9 | | AutoID device types are "Subtype of DeviceType defined in OPC 10000-100". OPC 30010 does not mention `DeviceSet`, Machinery or `Machines`. | OPC 30010 (AutoID) 1.01, 6.1 Table 7 | | "Each Machine providing this building block provides the Components Object, which directly references all identifiable components of a Machine." / "Note: That does not preclude that a Machine is organizing its components in various other hierarchies as well." | OPC 40001-1 (Machinery) 1.04.2, 11.1 | | "The MachineComponentsType provides HasComponent References to all identifiable components of a Machine"; Table 27: `0:HasComponent Object <Component> 0:BaseObjectType OP`; Table 28: `<Component> 0:HasAddIn Object 2:Identification MachineryItemIdentificationType M`; "As all identifiable components shall contain the Identification Object". | OPC 40001-1 1.04.2, 11.2 | | `MachineryItemIdentificationType`: `2:Manufacturer` M, `2:SerialNumber` M, all other members O. | OPC 40001-1 1.04.2, 8.3 Table 16 | | `MachineryComponentIdentificationType` is the component subtype of `MachineryItemIdentificationType` (adds `2:DeviceRevision`); "It is not recommended to use the optional Properties 2:RevisionCounter and 2:DeviceManual"; a missing component `2:ProductInstanceUri` "shall be omitted". | OPC 40001-1 1.04.2, 10.2 | | "it is recommended to keep this approach and let several References lead to the same Node, rather than duplicating the Node" / Robotics example: identification Properties "are only referenced from the Identification functional group". | OPC 40001-1 1.04.2, 7.3 and Annex B.2 (informative) | | "It is expected that domain-specific companion specifications using this specification will define what assets of the domain in what usage are considered to be Machines or components of Machines." | OPC 40001-1 1.04.2, 11.1 (Note) | | Organizes "can be used to span multiple hierarchies"; more than one hierarchical path to a Node is allowed. | OPC 10000-3 1.05, 7.11, 7.5 | **SDK decisions** (not specified by OPC 30010, which predates Machinery): - A reader is never added to `Machines`: no specification classifies an AutoID device as a machine (40001-1 11.1 leaves that to the domain specifications, AutoID is silent). - A reader becomes a component of a machine only when the application asks for it (`AsComponentOf`). - `WithParent` keeps its parent and adds the `DeviceSet` reference rather than rejecting parents outside `DeviceSet` (see below). - The component `Identification` references the reader's nameplate instead of copying it. - AutoID does not add Machinery conformance units; the Machinery owner advertises them for what its builders created. ### Why `WithParent` adds a reference instead of rejecting DI 4.9 asks for reachability, not for a particular parent, and OPC 10000-3 allows several hierarchical paths to one node. Adding `Organizes` from `DeviceSet` satisfies the "shall" while keeping the parent the application asked for, and it is the same reference type `DeviceSet` already uses for every reader (and the DI node manager for every device). Rejecting such parents with `Bad_ConfigurationError` would break existing callers that hang a reader below a machine or folder. When `DeviceSet` already reaches the parent, no reference is added, so the "only the root instance" rule of 4.9 holds for composed devices. Reachability is decided once when the reader is built, by following the forward hierarchical references of the owner's nodes. ### Why `AsComponentOf` keeps the reader referenced from `DeviceSet` Machinery does not require machines to be in `DeviceSet` (the stock builders put them in `Machines` only), so a reader that is only below `Components` would again be unreachable from `DeviceSet`. The same reachability rule as for `WithParent` applies: if the machine is itself reachable from `DeviceSet` (for example a machine that is also a DI device organized by `DeviceSet`), the reader is reached through it and gets no direct reference, otherwise `DeviceSet` organizes it. ### Shape of `AsComponentOf` It takes the machine's `BaseObjectState`, like `WithParent(NodeState)` and `IMachineryBuildContext.BindMachine(TState, ...)`; with the stock builders that is `IMachineHandle<TState>.State`. It requires the machine's `Components` add-in to exist (`IMachineBuilder.WithComponents`, or a companion-declared one bound with `WithComponents(components, ...)`) instead of creating one, so the Machinery builders stay the only place that creates Machinery building blocks (`MachineryBuildingBlocks` organization, facets). The reader's NodeState parent is the `Components` object, so the DI node manager attaches it with `HasComponent` and the existing registration and rollback code applies unchanged. ## Behaviour change for existing callers - `WithParent(parent)` with a parent that `DeviceSet` does not reach (for example a machine in `Machines`, or an application folder): the reader is now *also* browsable from `DeviceSet` (one extra `Organizes` reference, both directions), and an `AutoIdClient.EnumerateReadersAsync()` (which browses `DeviceSet`) now finds it. Its parent, NodeId and `HasComponent` reference are unchanged. - `WithParent(parent)` with a parent `DeviceSet` reaches (for example another reader): unchanged. - Default placement: unchanged. - `IAutoIdReaderBuilder` gains a member. The interface is implemented only inside the package (instances come from `AutoIdRuntime.Reader`), and the package is new in 2.0. ## Changes - `src/Opc.Ua.AutoID.Server/AutoIdReaderPlacement.cs` (new, internal): `DeviceSet` lookup, the reachability check, the `Organizes` reference and its removal, `Components` resolution and validation, the nameplate check, and the `Identification` add-in. - `src/Opc.Ua.AutoID.Server/AutoIdRuntime.cs`: the builder's nameplate delegate runs once (validated before anything is registered, then copied into the DI builder); `AsComponentOf` resolves `Components` as the parent before the device is created; after registration the add-in is added and the `DeviceSet` reference kept; rollback removes the reference. - `src/Opc.Ua.AutoID.Server/Builders/IAutoIdReaderBuilder.cs`, `AutoIdReaderBuilder.cs`: `AsComponentOf`, mutual exclusion with `WithParent`, XML docs stating the exact behaviour. - `docs/AutoID.md`: new "Placement" subsection under "Hosting readers" (what DI 4.9 and Machinery 11 require, what is an SDK decision, a table per builder call, an example), the composition table rows for readers and `Machines`, the test fixture table. `src/Opc.Ua.AutoID.Server/NugetREADME.md`: a short paragraph. - Tests (below). ## Tests - `AutoIdReaderLifecycleTests` (stand-alone owner): `ReadersCanHangBelowAnExplicitParentAsync` now also asserts that `DeviceSet` does not organize a reader below another reader; `AReaderBelowAParentOutsideTheDeviceSetIsStillOrganizedByItAsync`; `AFailedCreationBelowAnOutsideParentLeavesNoDeviceSetReferenceAsync` (rollback); `AStandaloneReaderCannotBeAComponentOfAMachineAsync` (`Bad_ConfigurationError` naming `AddAutoIdToMachinery`); argument checks for `AsComponentOf(null)` and both call orders of `WithParent`/`AsComponentOf`. - `AutoIdCompositionTests` (Machinery owner, in process): `AReaderBecomesAComponentOfAMachineAndStaysADeviceAsync` (parent is `Components`, `HasComponent`, `DeviceSet` organizes it, `Machines` does not, `Identification` with `HasAddIn` and the component type, `HasProperty` to the reader's own `Manufacturer`/`SerialNumber`/`Model` nodes, no `DeviceManual`, owned subtree); `AComponentNeedsAMachineWithComponentsAndRegistersNothingOtherwiseAsync`; `AComponentNeedsTheMandatoryIdentificationAsync`; `AComponentNeedsAMachineOfTheOwnerAsync`; `AFailedComponentCreationIsRolledBackAsync`. - `AutoIdPlacementEndToEndTests` (new, hosted Machinery + AutoID over a real session): a default reader, a reader below that reader, a reader `AsComponentOf` a machine and a reader `WithParent` the machine. Browsing `DeviceSet` finds the three roots and not the nested reader, the outer reader reaches the nested one; `MachineryClient.DiscoverMachinesAsync` returns only the machine and `Machines` contains no reader; `MachineryClient.EnumerateComponentsAsync` lists only the component reader, `ReadIdentificationAsync` reads `Manufacturer`, `SerialNumber`, `ProductCode` through the add-in, which is `HasAddIn`-referenced and typed `MachineryComponentIdentificationType` and whose `HasProperty` targets are the reader's own nameplate nodes; `AutoIdClient.EnumerateReadersAsync` finds the three roots. - The existing default assertions (`TheReadersShareTheDeviceSetWithThePumpAndStayOutOfTheMachinesFolderAsync`, `TheMachineryAndPumpsClientsStillFindTheirInstancesAsync`, `ComposedReadersLiveBelowTheSharedDeviceSetAsync`) are unchanged. - Fail-without: with the runtime wiring reverted to master (builder surface kept so the tests compile), 10 of the new tests fail: all `AsComponentOf` cases, both `WithParent` cases outside `DeviceSet` and the three session tests that look for the component or the extra `DeviceSet` reference. Removing only the rollback of the `DeviceSet` reference makes both rollback tests fail. `NoReaderBecomesAMachineAsync` and the existing default tests pass with and without the change, as intended. Local results (macOS arm64, SDK 10.0.402, head `238ed9b69` on master `9bfc780e1`; the same three commits are now based on master `ac24123dd` as `c6d70909b`, no AutoID file changed in between): `Opc.Ua.AutoID.Tests` net10.0 318/318 passed; `Opc.Ua.AutoID.Server` all TFMs and the test project with `CustomTestTarget=net48` build with 0 warnings, 0 errors (net48 tests run on Windows CI; there is no mono host here). `dotnet format whitespace/style/analyzers --verify-no-changes` on the changed files: no new findings beyond the conventions of the surrounding test files. ## Commits 1. `fix(autoid): keep every reader reachable from DeviceSet` 2. `feat(autoid): make a reader a component of a machine` 3. `docs(autoid): document where readers are placed` The branch is based on OPCFoundation/master `ac24123dd` (#4852, the last master commit with a green build). Fork CI was not repeated on this head: earlier fork runs of this branch failed only in test projects outside this PR (the master build was broken by the NUnit 5 migration #4840 and fixed in #4852; master is currently broken again in `Opc.Ua.Core.Tests` by #4849). The upstream checks of this PR test it against the current master. ## Related Issues - Follow-up to #4825 (tracking issue #4824). - #4823 (open) edits prose in other paragraphs of `docs/AutoID.md` (table of contents, the DI-owner paragraph, providers). This PR adds a subsection and changes two rows of the composition table and one row of the test table; no hunk overlaps. The table of contents is deliberately not touched here because #4823 adds the subsection entries for "Hosting readers"; the "Placement" entry can be added there or after both are merged. ## Checklist - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. (Local build of the changed projects: 0 warnings, 0 errors.) - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. (Locally the AutoID test project on net10.0; net48 is built but runs only on Windows CI.) - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. (Pending the upstream checks; see the CI note above.) - [x] I have addressed **all** PR feedback received. --------- Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
This commit is contained in:
co-authored by
Marc Schier
parent
a51f08ad6f
commit
0bd7aaaad6
+91
-4
@@ -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<BaseObjectState>? 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<MachineryNodeManager>(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 |
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Places a reader in the address space of its Device Integration owner so that it stays
|
||||
/// reachable from the DI <c>DeviceSet</c> (OPC 10000-100 4.9), and makes it a component of a
|
||||
/// machine (OPC 40001-1 11) on request.
|
||||
/// </summary>
|
||||
internal sealed class AutoIdReaderPlacement
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates the placement helper of a node manager.
|
||||
/// </summary>
|
||||
public AutoIdReaderPlacement(DiNodeManager manager)
|
||||
{
|
||||
m_manager = manager;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the DI <c>DeviceSet</c> of the node manager, or <see langword="null"/> when the
|
||||
/// manager does not publish one.
|
||||
/// </summary>
|
||||
public NodeState? FindDeviceSet()
|
||||
{
|
||||
return m_manager.FindPredefinedNode(NodeId.Create(
|
||||
Di.Objects.DeviceSet,
|
||||
DiNodeManager.DiNamespaceUri,
|
||||
m_manager.Server.NamespaceUris));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the <c>Components</c> add-in of <paramref name="machine"/> that a reader becomes
|
||||
/// a component of, and fails before anything is registered when the reader cannot be one.
|
||||
/// </summary>
|
||||
/// <exception cref="ServiceResultException">
|
||||
/// <c>Bad_ConfigurationError</c>: the owner has no Machinery model, the machine is not a
|
||||
/// node of the owner or it publishes no <c>Components</c> add-in, or more than one.
|
||||
/// </exception>
|
||||
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<BaseObjectState> 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];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Finds the <c>Components</c> add-ins of <paramref name="machine"/> the way Machinery binds
|
||||
/// them: the <c>MachineComponentsType</c> objects its <c>MachineryBuildingBlocks</c> folder
|
||||
/// references with <c>HasAddIn</c> (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.
|
||||
/// </summary>
|
||||
private List<BaseObjectState> FindComponentsAddIns(
|
||||
BaseObjectState machine,
|
||||
ushort machineryNamespace,
|
||||
NodeId componentsTypeId)
|
||||
{
|
||||
ServerSystemContext context = m_manager.SystemContext;
|
||||
TypeTable typeTree = m_manager.Server.TypeTree;
|
||||
var found = new List<BaseObjectState>();
|
||||
NodeState? buildingBlocks = machine.FindChild(
|
||||
context,
|
||||
new QualifiedName(MachineryBuildingBlocksBrowseName, machineryNamespace));
|
||||
if (buildingBlocks != null)
|
||||
{
|
||||
var references = new List<IReference>();
|
||||
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<BaseInstanceState>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether <paramref name="child"/> is referenced from its parent with a
|
||||
/// hierarchical reference, rather than for example <c>HasCondition</c>.
|
||||
/// </summary>
|
||||
private static bool IsHierarchical(TypeTable typeTree, BaseInstanceState child)
|
||||
{
|
||||
return !child.ReferenceTypeId.IsNull &&
|
||||
typeTree.IsTypeOf(child.ReferenceTypeId, Opc.Ua.Types.ReferenceTypeIds.HierarchicalReferences);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks the members OPC 40001-1 8.3 makes mandatory for the identification of a
|
||||
/// component before anything is registered.
|
||||
/// </summary>
|
||||
/// <exception cref="ServiceResultException">
|
||||
/// <c>Bad_ConfigurationError</c>: <c>Manufacturer</c> or <c>SerialNumber</c> is not set.
|
||||
/// </exception>
|
||||
public static void ValidateComponentNameplate(DeviceIdentificationData? nameplate, QualifiedName readerName)
|
||||
{
|
||||
var missing = new List<string>();
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns whether <c>DeviceSet</c> reaches <paramref name="target"/> through forward
|
||||
/// hierarchical references of nodes of the node manager.
|
||||
/// </summary>
|
||||
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<NodeId> { deviceSet.NodeId };
|
||||
var pending = new Queue<NodeState>();
|
||||
pending.Enqueue(deviceSet);
|
||||
var children = new List<BaseInstanceState>();
|
||||
var references = new List<IReference>();
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Organizes <paramref name="device"/> from <c>DeviceSet</c>, in both directions.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes what <see cref="Organize"/> added.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds the OPC 40001-1 <c>Identification</c> add-in of a component to the registered
|
||||
/// reader. It is typed <c>MachineryComponentIdentificationType</c> and references the
|
||||
/// reader's DI nameplate properties rather than copying them (OPC 40001-1 7.3, Annex B.2).
|
||||
/// </summary>
|
||||
/// <exception cref="ServiceResultException">
|
||||
/// <c>Bad_ConfigurationError</c>: the reader has an <c>Identification</c> child already.
|
||||
/// </exception>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nameplate properties of the reader that its component identification references:
|
||||
/// the members of <c>MachineryComponentIdentificationType</c> that the reader publishes
|
||||
/// with a value. <c>DeviceManual</c> and <c>RevisionCounter</c> are left out, as
|
||||
/// OPC 40001-1 10.2 recommends; the user-writable <c>AssetId</c> and <c>ComponentName</c>
|
||||
/// are referenced whenever the reader publishes them.
|
||||
/// </summary>
|
||||
private static IEnumerable<BaseVariableState> NameplateOf(AutoIdDeviceState device)
|
||||
{
|
||||
foreach (PropertyState<LocalizedText>? text in new[] { device.Manufacturer, device.Model })
|
||||
{
|
||||
if (text != null && !text.Value.IsNull && !string.IsNullOrEmpty(text.Value.Text))
|
||||
{
|
||||
yield return text;
|
||||
}
|
||||
}
|
||||
foreach (PropertyState<string>? 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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private const string MachineryBuildingBlocksBrowseName = "MachineryBuildingBlocks";
|
||||
|
||||
private readonly DiNodeManager m_manager;
|
||||
}
|
||||
}
|
||||
@@ -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<AutoIdRuntime>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
/// <exception cref="ArgumentOutOfRangeException">The provider reports an unknown reader kind.</exception>
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Keeps a reader placed below another parent reachable from the DI <c>DeviceSet</c>
|
||||
/// (OPC 10000-100 4.9): when <c>DeviceSet</c> does not reach the parent, it organizes the
|
||||
/// reader directly.
|
||||
/// </summary>
|
||||
/// <returns>The <c>DeviceSet</c> that now organizes the reader, else <see langword="null"/>.</returns>
|
||||
/// <exception cref="ServiceResultException">The node manager publishes no <c>DeviceSet</c>.</exception>
|
||||
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<TDevice>(
|
||||
AutoIdReaderBuilder builder,
|
||||
NodeState? parent,
|
||||
DeviceIdentificationData? nameplate,
|
||||
ExpandedNodeId typeId,
|
||||
Func<NodeState, TDevice> create,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -416,26 +498,46 @@ namespace Opc.Ua.AutoID.Server
|
||||
Di.Server.Builders.IDeviceBuilder<TDevice> 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));
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies the nameplate the builder's delegate produced once, so the delegate is not run
|
||||
/// a second time.
|
||||
/// </summary>
|
||||
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<AutoIdReader> m_readers = [];
|
||||
private readonly SemaphoreSlim m_createGate = new(1, 1);
|
||||
|
||||
@@ -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
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -51,11 +51,61 @@ namespace Opc.Ua.AutoID.Server.Builders
|
||||
public interface IAutoIdReaderBuilder
|
||||
{
|
||||
/// <summary>
|
||||
/// Organizes the device below <paramref name="parent"/> instead of the DI <c>DeviceSet</c>.
|
||||
/// Places the device below <paramref name="parent"/> (<c>HasComponent</c>) rather than
|
||||
/// directly below the DI <c>DeviceSet</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// OPC 10000-100 4.9 requires every <c>ComponentType</c> instance to be reachable from
|
||||
/// <c>DeviceSet</c> through hierarchical references, and only the root of a device composed
|
||||
/// of devices to be referenced from <c>DeviceSet</c> itself. When <c>DeviceSet</c> already
|
||||
/// reaches <paramref name="parent"/> (for example another reader, or a device it
|
||||
/// organizes), the reader is reached through it and gets no further reference. Otherwise
|
||||
/// <c>DeviceSet</c> also organizes the reader (<c>Organizes</c>), so it stays a starting
|
||||
/// point to locate the device.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// To make the reader a component of a Machinery machine, use
|
||||
/// <see cref="AsComponentOf"/> instead; the two cannot be combined.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="parent">The parent node, owned by the same node manager.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="parent"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <see cref="AsComponentOf"/> was called on this builder.
|
||||
/// </exception>
|
||||
IAutoIdReaderBuilder WithParent(NodeState parent);
|
||||
|
||||
/// <summary>
|
||||
/// Makes the device an identifiable component of a machine (OPC 40001-1 11).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Requires AutoID composed into the Machinery owner (<c>AddAutoIdToMachinery</c>, or a
|
||||
/// Device Integration owner that loads the Machinery model). The machine must be a node
|
||||
/// of that owner and publish the <c>Components</c> add-in, for example built with
|
||||
/// <c>IMachineBuilder.WithComponents</c>. When the reader is built, it is placed below
|
||||
/// <c>Components</c> (<c>HasComponent</c>) and gets the <c>Identification</c> add-in
|
||||
/// (<c>HasAddIn</c>) typed <c>MachineryComponentIdentificationType</c>. The add-in owns
|
||||
/// no copies of the nameplate: it references the reader's own DI nameplate properties
|
||||
/// (<c>Manufacturer</c>, <c>SerialNumber</c> and those of the other members that are set).
|
||||
/// Set the nameplate with <see cref="WithIdentification"/>; <c>Manufacturer</c> and
|
||||
/// <c>SerialNumber</c> are mandatory for a component and checked before anything is
|
||||
/// registered.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The reader stays a device: <c>DeviceSet</c> organizes it unless it already reaches
|
||||
/// the machine (OPC 10000-100 4.9, see <see cref="WithParent"/>). It is not added to the
|
||||
/// <c>Machines</c> folder.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="machine">The machine, a node of the Machinery owner.</param>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="machine"/> is null.</exception>
|
||||
/// <exception cref="InvalidOperationException">
|
||||
/// <see cref="WithParent"/> was called on this builder.
|
||||
/// </exception>
|
||||
IAutoIdReaderBuilder AsComponentOf(BaseObjectState machine);
|
||||
|
||||
/// <summary>
|
||||
/// Sets the <c>DeviceName</c> property (OPC 30010 6.1.3.14); defaults to the browse name.
|
||||
/// </summary>
|
||||
@@ -112,8 +162,9 @@ namespace Opc.Ua.AutoID.Server.Builders
|
||||
|
||||
/// <summary>
|
||||
/// Transfers ownership of the provider to the reader: it is disposed with the reader when it
|
||||
/// implements <see cref="IAsyncDisposable"/> or <see cref="IDisposable"/>. Without this the
|
||||
/// caller owns the provider.
|
||||
/// implements <see cref="IAsyncDisposable"/> or <see cref="IDisposable"/>, and also when
|
||||
/// <see cref="BuildAsync"/> fails while creating the reader. Without this the caller owns
|
||||
/// the provider.
|
||||
/// </summary>
|
||||
IAutoIdReaderBuilder OwnsProvider();
|
||||
|
||||
@@ -123,8 +174,10 @@ namespace Opc.Ua.AutoID.Server.Builders
|
||||
/// <param name="cancellationToken">Cancels the operation.</param>
|
||||
/// <returns>The live reader.</returns>
|
||||
/// <exception cref="ServiceResultException">
|
||||
/// The name is already taken below the parent (<c>Bad_BrowseNameDuplicated</c>) or the
|
||||
/// parent is not available (<c>Bad_ConfigurationError</c>).
|
||||
/// The name is already taken below the parent (<c>Bad_BrowseNameDuplicated</c>), the
|
||||
/// parent is not available, or the machine of <see cref="AsComponentOf"/> cannot take the
|
||||
/// reader as a component or the nameplate lacks a mandatory member
|
||||
/// (<c>Bad_ConfigurationError</c>).
|
||||
/// </exception>
|
||||
/// <exception cref="InvalidOperationException">The builder was already used.</exception>
|
||||
/// <exception cref="ObjectDisposedException">The runtime has been disposed.</exception>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<BaseObjectState> 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<BaseObjectState> press = await AddPressAsync(fixture, withComponents: false);
|
||||
var provider = new FakeProvider();
|
||||
|
||||
ServiceResultException error = (await Assert.ThrowsAsync<ServiceResultException>(
|
||||
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<BaseObjectState> press = await AddPressAsync(fixture, withComponents: true);
|
||||
|
||||
ServiceResultException none = (await Assert.ThrowsAsync<ServiceResultException>(
|
||||
async () => await runtime
|
||||
.Reader(fixture.Name("Infeed"), new FakeProvider())
|
||||
.AsComponentOf(press.State)
|
||||
.BuildAsync()))!;
|
||||
ServiceResultException noSerial = (await Assert.ThrowsAsync<ServiceResultException>(
|
||||
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<ServiceResultException>(
|
||||
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<BaseObjectState> 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<InvalidOperationException>(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<BaseObjectState> 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<ServiceResultException>(
|
||||
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 }));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a machine built without the Machinery builders: no <c>MachineryBuildingBlocks</c>
|
||||
/// folder, and <c>MachineComponentsType</c> children named in the companion's namespace.
|
||||
/// </summary>
|
||||
private static async Task<BaseObjectState> 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<IMachineHandle<BaseObjectState>> AddPressAsync(
|
||||
ComposedOwner fixture,
|
||||
bool withComponents)
|
||||
{
|
||||
IMachineryBuildContext context = fixture.Manager.CreateMachineryBuildContext();
|
||||
IMachineBuilder<BaseObjectState> 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<IReference>();
|
||||
node.GetReferences(context, references);
|
||||
return references.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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))!;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the owner's Machinery <c>Machines</c> folder.
|
||||
/// </summary>
|
||||
public NodeState MachinesFolder => Manager.FindPredefinedNode(
|
||||
ExpandedNodeId.ToNodeId(Machinery.ObjectIds.Machines, Manager.SystemContext.NamespaceUris))!;
|
||||
|
||||
/// <summary>
|
||||
/// Starts the server and creates the owner's address space.
|
||||
/// </summary>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>
|
||||
/// Reader placement in a hosted Machinery and AutoID server, read back over a real session:
|
||||
/// every reader stays reachable from the DI <c>DeviceSet</c> (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.
|
||||
/// </summary>
|
||||
[TestFixture]
|
||||
[Category("AutoID")]
|
||||
[Category("Composition")]
|
||||
[NonParallelizable]
|
||||
public sealed class AutoIdPlacementEndToEndTests
|
||||
{
|
||||
[OneTimeSetUp]
|
||||
public async Task OneTimeSetUpAsync()
|
||||
{
|
||||
var configured = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var services = new ServiceCollection();
|
||||
services.AddLogging();
|
||||
services.AddOpcUa()
|
||||
.AddServer<StandardServer>(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<MachineryNodeManager>(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<IHostedService>().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<MachineEntry>();
|
||||
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<AutoIdReaderEntry>();
|
||||
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<string[]> 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<BrowseResult> 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<BaseObjectState>? m_press;
|
||||
private AutoIdReader? m_gate;
|
||||
private AutoIdReader? m_antenna;
|
||||
private AutoIdReader? m_infeed;
|
||||
private AutoIdReader? m_outfeed;
|
||||
}
|
||||
}
|
||||
@@ -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<ArgumentNullException>(() => builder.WithParent(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => builder.AsComponentOf(null!));
|
||||
Assert.Throws<InvalidOperationException>(() => runtime.Reader(m_fixture.Name(Unique("X")), rfid)
|
||||
.WithParent(new BaseObjectState(null)).AsComponentOf(new BaseObjectState(null)));
|
||||
Assert.Throws<InvalidOperationException>(() => runtime.Reader(m_fixture.Name(Unique("X")), rfid)
|
||||
.AsComponentOf(new BaseObjectState(null)).WithParent(new BaseObjectState(null)));
|
||||
Assert.Throws<ArgumentException>(() => builder.WithDeviceName(string.Empty));
|
||||
Assert.Throws<ArgumentNullException>(() => builder.WithDeviceInfo(null!));
|
||||
Assert.Throws<ArgumentNullException>(() => 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<InvalidOperationException>(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<ServiceResultException>(
|
||||
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<Exception>(
|
||||
async () => await builder.BuildAsync().ConfigureAwait(false)))!;
|
||||
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(error, failure == "Identification"
|
||||
? Is.InstanceOf<InvalidOperationException>()
|
||||
: Is.InstanceOf<ServiceResultException>());
|
||||
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<IReference>();
|
||||
node.GetReferences(context, references);
|
||||
return references.Count;
|
||||
}
|
||||
|
||||
private static async Task<AutoIdManagerFixture> StartOwnFixtureAsync()
|
||||
{
|
||||
var own = new AutoIdManagerFixture();
|
||||
|
||||
Reference in New Issue
Block a user