Files
Marc Schier cdb4d82d58 Unify DI companion composition, placement and resource ownership (#4915)
# Description

DI companion models can share one address-space owner without depending
on the Machinery server pipeline or creating another DeviceSet. This
implements steps A-E of #4872.

- Add `IDiModelProvider` and neutral model registration. DI-owning
managers merge neutral providers with their family registrations, retain
each instance once by reference identity, and preserve the original
provider's type-name ordering through adapters. Robot Intent and Vision
without Assets load only their family providers because they do not own
DI. Vision's Assets dependency loader runs before neutral providers,
including providers with `Order = int.MinValue`.
- Add `AddDiModel<TProvider, TNodeManager>(configure)` with synchronous
and awaited callbacks through the existing DI post-setup pipeline. Model
registration is shared; callbacks target the selected manager type and
run in registration order. Register Machinery configuration before
dependent AutoID configuration. No registration-only AutoID/Machinery
package is needed. The existing
`OpcUaAutoIdCompositionExtensions.AddAutoIdToMachinery` API remains in
the AutoID server assembly as an obsolete forwarder to `AddAutoIdModel`.
- Centralize companion lifetime on `DiNodeManager`. Final shutdown
atomically closes registration and joins pending drains before manager
teardown, including their failures. Ordinary explicit drains permit
later batches. Machinery rearms its cached cleanup bridge when adopting
another machine-resource batch.
- Add shared DeviceSet reachability and placement helpers. Runtime DI
components link through the outermost composed component rather than
each nested device. Public organization and notifier hooks replace
companion friend-assembly access. Pumps and Scales resolve Machines
through its generated model identifier; the protected compatibility
helper remains namespace-qualified.
- Consolidate composition examples, ordering, ownership and
specification-based placement in the existing [Device Integration
guide](docs/DeviceIntegration.md#composing-companion-models). Add
summaries to the changed internal types and their public members.

The generic helper and explicit callback ordering replace the earlier
registration-only package design in response to review feedback. All
thirteen review threads have a reply describing the published correction
and are resolved.

### Validation

The latest correction passed both affected net10.0 suites and the
focused net48
regressions. Commands used `TEMP=TMP=D:\temp`, without warning
demotions:

```powershell
dotnet test tests\Opc.Ua.AutoID.Tests\Opc.Ua.AutoID.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 -m:1 -p:BuildInParallel=false --logger trx --results-directory D:\temp\issue4872-feedback2-net10.0 -v quiet --no-restore
# Passed 358; exit 0.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 -m:1 -p:BuildInParallel=false --logger trx --results-directory D:\temp\issue4872-feedback2-net10.0 -v quiet --no-restore
# Passed 713; one existing skip; exit 0.
dotnet test tests\Opc.Ua.AutoID.Tests\Opc.Ua.AutoID.Tests.csproj -p:CustomTestTarget=net48 -f net48 -m:1 -p:BuildInParallel=false --logger trx --results-directory D:\temp\issue4872-feedback2-net48 -v quiet --filter 'FullyQualifiedName~LegacyMachineryRegistration'
# Passed 2; exit 0.
dotnet test tests\Opc.Ua.Vision.Tests\Opc.Ua.Vision.Tests.csproj -p:CustomTestTarget=net48 -f net48 -m:1 -p:BuildInParallel=false --logger trx --results-directory D:\temp\issue4872-feedback2-net48 -v quiet --filter 'FullyQualifiedName~VisionFactoriesLoadNeutralProvidersOnlyWhenOwningDiAsync|FullyQualifiedName~VisionAdaptersPreserveOriginalProviderTypeNameTies'
# Passed 17; exit 0.
```


`LegacyMachineryRegistrationForwardsOptionsAndDeduplicatesTheNeutralModel`
and
`LegacyMachineryRegistrationPreservesNeutralValidation` cover the legacy
entry
point's options, provider deduplication, owner preservation and
validation.
`VisionFactoriesLoadNeutralProvidersOnlyWhenOwningDiAsync` includes four
minimum-priority Assets cases across both factories and both
registration orders.

Full-solution validation remains incomplete. The earlier net10.0 attempt
had a
Windows clipboard access failure and stopped during Stress.Tests without
a final
result. The earlier net48 solution attempt failed because target assets
were
missing; the focused commands above restore successfully. Coverage
percentages
and current CI/CodeQL results were not collected locally.

## Related Issues

Implements the proposal and maintainer answers in the tracking issue,
with the generic helper and configuration order confirmed during review.

- Fixes #4872

## Checklist

_Put an `x` in the boxes that apply. You can complete these step by step
after opening the PR._

- [ ] 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.
- [ ] 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.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.
2026-10-27 14:34:54 +01:00

880 lines
47 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# OPC UA for Pumps and Vacuum Pumps (OPC 40223)
The `Opc.Ua.Pumps*` packages implement
[OPC 40223](https://reference.opcfoundation.org/specs/OPC-40223), the
companion specification for pumps, compressors and vacuum pumps, on top of
OPC 40001-1 Machinery and OPC 10000-100 Device Integration. A server
publishes pumps with a fluent builder; a client discovers them and reads
them back into typed snapshots. Both sides share one set of contracts.
## Contents
- [Library layout](#library-layout)
- [The model](#the-model)
- [Where a pump lives](#where-a-pump-lives)
- [Quick start — server](#quick-start--server)
- [Quick start — client](#quick-start--client)
- [Server hosting](#server-hosting)
- [`AddPumps` and `ConfigurePumps`](#addpumps-and-configurepumps)
- [Options](#options)
- [Load order](#load-order)
- [Creating pumps](#creating-pumps)
- [Coexisting with a server that already owns DI](#coexisting-with-a-server-that-already-owns-di)
- [Direct composition and lifetime](#direct-composition-and-lifetime)
- [Builder reference](#builder-reference)
- [Nothing is materialised until it is asked for](#nothing-is-materialised-until-it-is-asked-for)
- [Addressing children by browse name](#addressing-children-by-browse-name)
- [`IPumpBuilder`](#ipumpbuilder)
- [`IPumpGroupBuilder`](#ipumpgroupbuilder)
- [The nameplate](#the-nameplate)
- [Ports](#ports)
- [Vibration measurements and other placeholders](#vibration-measurements-and-other-placeholders)
- [Documentation files](#documentation-files)
- [Updating values at runtime](#updating-values-at-runtime)
- [Client reference](#client-reference)
- [Creating the client](#creating-the-client)
- [Discovery](#discovery)
- [Reading groups](#reading-groups)
- [Value sets, not records](#value-sets-not-records)
- [Read options](#read-options)
- [Supervision](#supervision)
- [Maintenance](#maintenance)
- [Contracts reference](#contracts-reference)
- [Conformance matrix](#conformance-matrix)
- [Profiles and conformance units](#profiles-and-conformance-units)
- [Model](#model)
- [Server](#server)
- [Client](#client)
- [Samples](#samples)
- [Testing](#testing)
- [Troubleshooting](#troubleshooting)
- [Model provenance](#model-provenance)
- [Generator gap this model uncovered](#generator-gap-this-model-uncovered)
- [See also](#see-also)
## Library layout
| Package | Contents |
| --- | --- |
| `Opc.Ua.Pumps` | The source-generated OPC 40223 model (typed node states, `ObjectTypeIds`, `BrowseNames`, the 13 enumerations and 5 option sets, `PhysicalAddressDataType`, `AddOpcUaPumps`), `PumpsModel`, and the snapshot contracts both sides share |
| `Opc.Ua.Pumps.Server` | `PumpsNodeManager`, the shared `PumpsRuntime`, the fluent builders `IPumpBuilder` / `IPumpGroupBuilder`, and standalone or additive Machinery hosting |
| `Opc.Ua.Pumps.Client` | `PumpsClient` for discovery, nameplate, configuration, measurements, supervision, maintenance, ports and whole-pump snapshots, plus `AddPumpsClient()` |
The contracts (`PumpNameplate`, `PumpValueSet`, `PumpSupervisionStatus`,
`PumpSnapshot`, …) live in the model package on purpose. A server fills them
from its process and a client reads them back, and neither side references the
other.
All three packages target the stack's library frameworks and are marked
NativeAOT-compatible on `net10.0`.
## The model
| Model URI | C# namespace | `Namespaces` member | Model loader |
| --- | --- | --- | --- |
| `http://opcfoundation.org/UA/Pumps/` | `Opc.Ua.Pumps` | `Pumps` | `AddOpcUaPumps` |
The NodeSet defines 51 object types, 19 data types and roughly 8,900
variables. OPC 40223 defines no methods of its own; the 408 methods in the
NodeSet all belong to the `FileType` instances of the documentation groups.
`PumpType` is the entry point. It derives directly from the OPC 10000-100 DI
`TopologyElementType` (not from DI `ComponentType` and not from any Machinery
type) and carries seven groups:
| Group | Browse-name namespace | Type | What it holds |
| --- | --- | --- | --- |
| `Identification` | DI | `PumpIdentificationType` | The nameplate. Mandatory |
| `Configuration` | DI | `ConfigurationGroupType` | `Design` (78 parameters), `Implementation` (33), `SystemRequirements` (37) |
| `Operational` | DI | `OperationalGroupType` | `Measurements` (50), `Signals` (21), `Control`, `PumpActuation`, `BypassActuation`, `ThrottleValveActuation`, `MultiPump` |
| `Maintenance` | DI | `MaintenanceGroupType` | `GeneralMaintenance`, `PreventiveMaintenance`, `ConditionBasedMaintenance`, `BreakdownMaintenance` |
| `Events` | Pumps | `SupervisionType` | Seven supervision categories, about 150 boolean fault signals |
| `Ports` | Pumps | `PortsGroupType` | Drive, inlet-connection and outlet-connection ports |
| `Documentation` | Pumps | `DocumentationType` | 22 document/link pairs |
**Mind the namespace column.** `Identification`, `Configuration`,
`Maintenance` and `Operational` are OPC 10000-100 §5.6 *instance* names in
the DI namespace, not nodes of the Pumps model, so the Pumps generator emits
no `BrowseNames` constants for them. They are in `PumpsModel.DiGroups`
instead. Qualifying one of these four with the Pumps namespace index is the
most common way to get a Pumps browse path wrong, and it fails silently: the
path simply does not resolve.
Children of a group are spread over namespaces too. The nameplate is the
extreme case: eleven fields come from OPC 10000-100, four from OPC 40001-1
and eleven from OPC 40223. The builder and the client search the Pumps, DI,
Machinery and core namespaces in that order, so callers pass bare browse
names and never have to know which namespace a field lives in.
`MultiPumpType`, despite the name, is not a pump. It is a functional group
below `Operational` describing one pump's role inside a multi-pump set.
## Where a pump lives
A `PumpType` is a Device Integration topology element that OPC 40001-1
clients also treat as a machine, so a conforming server publishes it twice:
- as a child of the DI `DeviceSet`, and
- through an `Organizes` reference from the Machinery `Machines` folder.
`PumpsRuntime`, used by both the standalone and composed hosts, does both by default
(`PumpsServerOptions.OrganizeIntoMachinesFolder`), and
`PumpsClient.EnumeratePumpsAsync` looks in both places and de-duplicates.
Matching goes by type hierarchy, so a vendor subtype of `PumpType` is found
as well.
## Quick start — server
```csharp
using Opc.Ua;
using Opc.Ua.Pumps;
using Opc.Ua.Pumps.Server;
using Opc.Ua.Pumps.Server.Builders;
// Opc.Ua and Opc.Ua.Pumps both declare BrowseNames, and System declares Range.
using BrowseNames = Opc.Ua.Pumps.BrowseNames;
using Range = Opc.Ua.Range;
services.AddOpcUa()
.AddServer<StandardServer>(ConfigureServer)
.AddPumps()
.ConfigurePumps(async context =>
{
IPumpBuilder pump = await context.Manager.CreatePumpAsync(
new QualifiedName("Pump_1", context.Manager.InstanceNamespaceIndex),
context.CancellationToken);
pump.WithNameplate(new PumpNameplate
{
NodeId = NodeId.Null,
Manufacturer = new LocalizedText("Acme Pumps"),
SerialNumber = "SN-001",
Model = new LocalizedText("PumpX-2000"),
Location = "Plant 1 / Utility Skid",
CountryOfOrigin = "DE"
});
pump.Measurements
.SetAnalog(BrowseNames.DifferentialPressure, 350_000,
new EUInformation { DisplayName = new LocalizedText("Pa") },
new Range { Low = 0, High = 1_000_000 })
.SetAnalog(BrowseNames.MassFlow, 12.5);
pump.Signals.SetDiscrete(BrowseNames.PumpOperation, true);
pump.Supervision(BrowseNames.SupervisionProcessFluid)
.SetDiscrete(BrowseNames.Cavitation, false);
pump.MaintenanceCategory(BrowseNames.GeneralMaintenance)
.Set(BrowseNames.StateOfTheItem, Variant.From(StateOfTheItemEnum.OperatingState));
pump.AddPort(PumpPortKind.InletConnection, "Suction")
.Set(BrowseNames.Direction, Variant.From(PortDirectionEnum.In));
});
```
The pump publishes exactly what the configurator set: the nameplate fields,
two measurements with their unit and range, one signal, one supervision flag,
one maintenance state and one port. Everything else in the ~3,000-node
`PumpType` surface stays unmaterialised.
## Quick start — client
```csharp
using Opc.Ua.Pumps;
using Opc.Ua.Pumps.Client;
using BrowseNames = Opc.Ua.Pumps.BrowseNames;
PumpsClient pumps = session.Pumps(telemetry);
if (!pumps.IsSupported)
{
return; // the server does not publish OPC 40223
}
await foreach (PumpEntry entry in pumps.EnumeratePumpsAsync(ct))
{
PumpNameplate? nameplate = await pumps.ReadNameplateAsync(entry.NodeId, ct);
PumpValueSet measurements = await pumps.ReadMeasurementsAsync(entry.NodeId, cancellationToken: ct);
double? flow = measurements.GetDouble(BrowseNames.MassFlow);
EUInformation? unit = measurements[BrowseNames.MassFlow]?.EngineeringUnits;
PumpSupervisionStatus? supervision = await pumps.ReadSupervisionAsync(entry.NodeId, cancellationToken: ct);
foreach ((string group, PumpValue signal) in supervision?.ActiveSignals ?? [])
{
Console.WriteLine($"{nameplate?.SerialNumber}: {group}/{signal.Name}");
}
}
```
Or the whole pump in one pass:
```csharp
PumpSnapshot snapshot = await pumps.ReadPumpAsync(entry.NodeId, cancellationToken: ct);
```
## Server hosting
### `AddPumps` and `ConfigurePumps`
| Call | Effect |
| --- | --- |
| `AddPumps(Action<PumpsServerOptions>? configure = null)` | Registers `PumpsNodeManager` through `PumpsNodeManagerFactory`, loads DI, IA, Machinery and Pumps, and claims the Device Integration address space |
| `ConfigurePumps(Func<IPumpsSetupContext, ValueTask>)` / `ConfigurePumps(Action<IPumpsSetupContext>)` | Runs once the address space is initialised. Several registrations run in registration order; an exception aborts server startup |
`IPumpsSetupContext` gives the configurator the initialised `Manager`, the
underlying DI context (`DiContext`), the hosting `CancellationToken` and
`GetRequiredService<T>()` for application services, e.g. the process
connection that supplies the values.
`PumpsNodeManager` derives from `DiNodeManager`, so
`ConfigureDevicesFor<PumpsNodeManager>()` works on it unchanged;
`ConfigurePumps` is the typed shortcut.
### Options
| `PumpsServerOptions` | Default | Meaning |
| --- | --- | --- |
| `OrganizeIntoMachinesFolder` | `true` | Also reference every pump from the OPC 40001-1 `Machines` folder. Turning it off hides pumps from Machinery clients |
| `LoadIndustrialAutomationModel` | `true` | Load OPC 10000-200 IA. Machinery types `MonitoringType/Status/Stacklight` with the IA `BasicStacklightType`, so without IA the Machinery model fails to load; there is rarely a reason to turn this off |
| `AdditionalNamespaceUris` | `[]` | Extra model or vendor-extension namespaces registered by the owner |
### Load order
The load order is fixed: DI, then IA, then Machinery, then Pumps. IA has to be
in the address space before Machinery loads, and Pumps builds on both.
### Creating pumps
| `PumpsNodeManager` / `PumpsRuntime` member | Meaning |
| --- | --- |
| `CreatePumpAsync(QualifiedName browseName, CancellationToken)` | Creates a pump below the DI `DeviceSet`. A browse name already taken there is rejected with `BadBrowseNameDuplicated` |
| `CreatePumpAsync(QualifiedName, NodeState? parent, CancellationToken)` | Same, below an explicit parent |
| `Pump(PumpState)` / `Pump(NodeId)` | A builder for a pump the manager already created (`null` for an unknown NodeId) |
| `Pumps` | Every pump created so far, in creation order |
| `NamespaceIndices` | The Pumps, DI and Machinery namespace indices, resolved once |
`CreatePumpAsync` does not use `DiNodeManager.CreateDeviceAsync`: that method
requires a `ComponentType` descendant, and OPC 40223 derives `PumpType`
directly from DI `TopologyElementType`, the base of `ComponentType`. Each pump gets its own instance NodeIds, is registered
before the builder is returned (groups added later are visible immediately),
and is marked `SubscribeToEvents` so clients can subscribe to it as an event
notifier.
### Coexisting with a server that already owns DI
`AddPumps` claims the Device Integration address space, so it cannot be
combined with `AddOpcUaDi` or another DI-owning registration; the second
registration throws `InvalidOperationException` at startup. A server that
uses the stock Machinery owner instead calls `AddPumpsToMachinery`. Do not
combine `AddMachinery` and `AddPumps`.
```csharp
using Microsoft.Extensions.DependencyInjection;
using Opc.Ua;
using Opc.Ua.Machinery.Server;
using Opc.Ua.Pumps.Server;
using Opc.Ua.Server;
services.AddOpcUa()
.AddServer<StandardServer>(ConfigureServer)
.AddMachinery(options =>
{
options.Parts = MachineryParts.BuildingBlocks;
options.InstanceNamespaceUri = "urn:acme:plant:instances";
})
.AddPumpsToMachinery()
.ConfigurePumpsFor<MachineryNodeManager>(async (context, ct) =>
{
PumpsRuntime pumps = context.Runtime;
var pump = await pumps.CreatePumpAsync(
new QualifiedName("FeedPump", pumps.InstanceNamespaceIndex), ct);
pump.WithNameplate(new Opc.Ua.Pumps.PumpNameplate
{
NodeId = NodeId.Null,
Manufacturer = new LocalizedText("Acme"),
SerialNumber = "P-1",
ProductInstanceUri = "urn:acme:pump:P-1"
});
pump.Measurements.SetAnalog(Opc.Ua.Pumps.BrowseNames.MassFlow, 12.5);
});
```
`AddMachinery` must precede `AddPumpsToMachinery`, and its selected parts must
include `BuildingBlocks`. `PumpsMachineryModelProvider` contributes only Pumps
and any additional configured namespaces; the built-in Machinery provider
loads DI, IA and Machinery first. The Machinery owner controls these shared
dependencies, so `LoadIndustrialAutomationModel` affects standalone hosting only.
The server package references `Opc.Ua.Machinery.Server`, but standalone servers
continue to load the same four models, not every Machinery part.
`ConfigurePumpsFor<TNodeManager>` accepts
`Func<IPumpsBuildContext, CancellationToken, ValueTask>`. Its `Runtime` uses
the same builders as `PumpsNodeManager.Runtime`. `Manager` is the existing
DI/Machinery owner, and `GetRequiredService<T>()` resolves application services.
Successive configurators share one runtime and run in the Machinery configuration
pipeline before its builder is sealed. Optional groups remain lazy, including
groups added after startup, and their updates still notify subscribed clients.
Pumps and other companions can therefore share one owner, for example by also
calling [`AddScalesToMachinery`](Scales.md#composing-with-machinery-and-other-companions).
Model namespaces identify types and browse names; all newly created instance
NodeIds use the Machinery owner's application namespace. Do not repeat that
instance URI in `AdditionalNamespaceUris`.
For [AMB](AMB.md), add `AddAssetManagement()` and register a pump inside its
configurator through the actual owner:
```csharp
await context.GetRequiredService<Opc.Ua.AMB.Server.Assets.IAssetManagement>()
.RegisterAssetAsync(
context.Manager.TopologyElement(pump.Pump).Node,
asset => asset.WithConfigurableAssetId("P-1"),
ct);
```
No second DI manager is created. AMB remains a sidecar and the asset keeps
its native `PumpType`.
### Direct composition and lifetime
Without dependency injection, supply `new PumpsMachineryModelProvider()` alongside
`new MachineryModelProvider(MachineryParts.BuildingBlocks)` to the
`MachineryNodeManager` constructor. Providers are dependency-ordered even if
supplied in reverse order. After the address space has initialized:
```csharp
PumpsRuntime pumps = owner.CreatePumpsRuntime();
var pump = await pumps.CreatePumpAsync(
new QualifiedName("FeedPump", pumps.InstanceNamespaceIndex), ct);
```
The first `CreatePumpsRuntime(options)` call selects the options; later calls
return that same runtime. A standalone `PumpsNodeManager` returns its existing
`Runtime`. The stock Machinery manager disposes its composed runtime on shutdown,
cancelling queued creations and awaiting the active creation. Keep builders only
for the owner's lifetime.
An application-owned Machinery-compatible DI manager may instead load the same
models itself and call `CreatePumpsRuntime`. The DI owner releases registered
resources automatically; explicit `DisposeMachineryResourcesAsync()` calls remain safe.
No inheritance change to the sealed stock managers is required.
The [PumpDeviceIntegrationServer sample](../samples/Di/PumpDeviceIntegrationServer)
follows the same pattern in its own `DiNodeManager` subclass, because the same
manager also owns OpenUSD. It still source-generates its own copies of the
Machinery and Pumps models, which other test suites use as fixtures; moving it
onto these packages is left to a follow-up.
## Builder reference
### Nothing is materialised until it is asked for
The full `PumpType` surface is roughly 3,000 nodes. A pump that reports a flow
rate and a cavitation flag should publish those two, not 3,000 mostly empty
variables that cost every client a browse and a read to find out there is
nothing there. Every group and every leaf is therefore created on first use.
Touching `pump.Measurements` creates `Operational` and `Measurements`;
`SetAnalog(BrowseNames.MassFlow, …)` then creates `MassFlow` and, if a unit or
range is passed, its `EngineeringUnits` and `EURange` properties.
### Addressing children by browse name
Both the builder and the value sets address everything below a group by
browse name, using the generated `Opc.Ua.Pumps.BrowseNames` constants.
`DesignType` alone declares 78 optional variables and the model some 3,000
across its types, so a method or property per child would make an unusable
API and an unmaintainable one. Passing a generated constant keeps the call
compile-time checked all the same: a rename in the model breaks the build.
A name the group does not declare is **rejected** with an
`ArgumentException`, rather than silently creating a node. A server that
invents children is worse than one that omits them, because a client cannot
tell a vendor extension from a typo.
### `IPumpBuilder`
| Member | Returns a builder for |
| --- | --- |
| `Identification`, `Configuration`, `Operational`, `Events`, `Maintenance`, `Documentation` | The seven top-level groups (ports via `AddPort`) |
| `Design`, `Implementation`, `SystemRequirements` | The three `Configuration` sub-groups |
| `Measurements`, `Signals`, `Control`, `PumpActuation`, `MultiPump` | `Operational` sub-groups. `BypassActuation` and `ThrottleValveActuation` via `Operational.Nested(...)` |
| `Supervision(string)` | One of the seven `Events` categories (`BrowseNames.Supervision…`) |
| `MaintenanceCategory(string)` | One of the four `Maintenance` categories (`BrowseNames.…Maintenance`) |
| `WithNameplate(PumpNameplate)` | Fills `Identification` from the shared contract; see below |
| `AddPort(PumpPortKind, string name)` | Creates a drive, inlet or outlet port below `Ports` and returns its builder |
| `With(Action<PumpState>)` | Escape hatch for anything else on the pump node |
| `Pump`, `NodeId` | The pump itself |
### `IPumpGroupBuilder`
| Member | Effect |
| --- | --- |
| `Add(params string[] browseNames)` | Materialises children without setting values |
| `Set(string browseName, Variant value)` | Materialises a variable, sets its value, status `Good` and timestamp, and publishes the change |
| `SetAnalog(string, double, EUInformation?, Range?)` | As `Set`, and also publishes `EngineeringUnits` / `EURange` if the child declares them |
| `SetDiscrete(string, bool)` | Sets a boolean in either shape OPC 40223 uses (see below) |
| `Nested(string)` | Builder for a nested group (an object child) |
| `AddVibration(string name)` | Creates a named `<Vibration>` instance in a `Measurements` group; see [below](#vibration-measurements-and-other-placeholders) |
| `Child(string)` | The materialised child node, or `null` when the group declares no such child |
| `With(string, Action<BaseInstanceState>)` | Escape hatch: access levels, historizing, a method callback, `OnReadValue` |
| `Node` | The group node |
`SetDiscrete` covers both boolean shapes in the model:
- the `TwoStateDiscreteType` **variables** of the supervision groups
(`Cavitation`, `Dry`, `MotorOverheat`, …), which carry the state
themselves;
- the `DiscreteInputObjectType` / `DiscreteOutputObjectType` **objects** of the
signal and actuation groups (`PumpOperation`, …), whose state sits one level
down in `DiscreteInputValue` or `DiscreteOutputValue`.
### The nameplate
`WithNameplate` publishes every field of `PumpNameplate` that is set and
leaves the rest unmaterialised. `PumpNameplate.NodeId` and `MarkingsFolderId`
are ignored on the server side; they are filled in by the client when reading.
| Fields | Namespace |
| --- | --- |
| `Manufacturer`, `ManufacturerUri`, `Model`, `ProductCode`, `HardwareRevision`, `SoftwareRevision`, `DeviceClass`, `SerialNumber`, `ProductInstanceUri`, `AssetId`, `ComponentName` | OPC 10000-100 DI |
| `Location`, `InitialOperationDate`, `YearOfConstruction`, `MonthOfConstruction` | OPC 40001-1 Machinery |
| `DayOfConstruction`, `ArticleNumber`, `OrderProductCode`, `TypeOfProduct`, `Supplier`, `CountryOfOrigin`, `FabricationNumber`, `GTINCode`, `NationalStockNumber`, `PhysicalAddress` | OPC 40223 Pumps |
### Ports
`PortsGroupType` declares its ports as placeholders: which ports a pump has is
an instance property, not a type property. `AddPort` therefore creates the
instance from the port type and names it. The returned builder reaches the
port's own groups:
| `PumpPortKind` | Type | Groups |
| --- | --- | --- |
| `Drive` | `DrivePortType` | `Design`, `Measurements` |
| `InletConnection` | `InletConnectionPortType` | `Design`, `Implementation`, `Measurements`, `SystemRequirements` |
| `OutletConnection` | `OutletConnectionPortType` | `Design`, `Implementation`, `Measurements`, `SystemRequirements` |
```csharp
pump.AddPort(PumpPortKind.OutletConnection, "Discharge")
.Set(BrowseNames.Direction, Variant.From(PortDirectionEnum.Out));
```
### Vibration measurements and other placeholders
OPC 40223 §7.32 declares vibration measurements as the OptionalPlaceholder
`<Vibration>` of `MeasurementsType`, typed `VibrationMeasurementType` (§7.33,
35 analog values after IEC 61987 plus `ReferenceStandardForVibrationMeasurement`).
A pump publishes one instance per measuring point, each named by the
application and created with `AddVibration`:
```csharp
pump.Measurements.AddVibration("DriveEndBearing")
.SetAnalog(BrowseNames.OverallVibrationVelocityRMS, 2.8,
new EUInformation { DisplayName = new LocalizedText("mm/s") })
.Set(BrowseNames.ReferenceStandardForVibrationMeasurement, Variant.From("ISO 10816-3"));
```
Asking again for an existing name returns the same instance. A group without
a `<Vibration>` placeholder rejects the call.
A placeholder is a template, not a child: `Nested("Vibration")` fails with
*"'Measurements' declares '<Vibration>' as a placeholder, not as a child -
create named instances with AddVibration(name)"*. The model has three more
placeholder kinds:
| Placeholder | Type | Builder API |
| --- | --- | --- |
| `<Vibration>` in `MeasurementsType` | `VibrationMeasurementType` | `AddVibration(name)` |
| `<Drive>`, `<InletConnection>`, `<OutletConnection>` in `PortsGroupType` | the three port types | `AddPort(kind, name)` |
| `<Marking>` in `MarkingsType` | `FileType` | none; a marking is a file, see [Documentation files](#documentation-files) |
### Documentation files
The `Documentation` group pairs each document link with a `FileType` object.
The library publishes the links; it does **not** implement the `FileType`
methods (`Open`, `Read`, …). To serve the files, attach the handlers yourself
through `With(browseName, node => …)`, or leave the file objects out and
publish only the links.
## Updating values at runtime
The builder is also how an application keeps values current, e.g. from a
polling loop against the pump controller:
```csharp
IPumpBuilder pump = manager.Pump(pumpNodeId)!;
pump.Measurements.SetAnalog(BrowseNames.MassFlow, reading.MassFlow);
pump.Supervision(BrowseNames.SupervisionProcessFluid)
.SetDiscrete(BrowseNames.Cavitation, reading.Cavitation);
```
Every `Set`, `SetAnalog` and `SetDiscrete` stamps the value with status
`Good` and the current time and publishes the change. `PumpsNodeManager`
reports data changes by exception, so publishing triggers monitored
items; a client subscribed to `MassFlow` sees each update, and
`PumpsClientServerE2eTests` checks exactly that over a real subscription.
For a signal object the publish happens on the `DiscreteInputValue` /
`DiscreteOutputValue` child that carries the state, which is also the node a
client subscribes to (`PumpValue.NodeId`).
Keep the builders you need, as the
[PumpsServer sample](../samples/Pumps/PumpsServer/PumpFleet.cs) does, instead
of resolving them on every update. Values that are only ever read and never
subscribed to can instead be bound to their source once with
`With(browseName, node => ((BaseVariableState)node).OnReadValue = …)`.
## Client reference
For bounded interactive group reads, choose the overload with an explicit limit:
```csharp
PumpsClient pumps = session.Pumps(telemetry, maximumBrowseReferences: 256);
PumpValueSet values = await pumps.ReadMeasurementsAsync(
pumpId, new PumpReadOptions { IncludeTimestamps = true }, cancellationToken);
```
The limit applies to each browse and to a value-set read including its nested
groups. Exceeding it fails instead of truncating the result. This mode also
rejects incomplete value-set responses, ambiguous member resolution, duplicate
value names and invalid engineering metadata. Denied or uncertain service
responses are not treated as optional absence. The same overload is available
on the injected `PumpsClientFactory.CreateAsync`. Constructor and factory
overloads without an explicit limit do not enable this bounded-read mode.
Continuation cleanup accepts an empty successful release response without masking
the original limit error or cancellation. A failed release preserves both failures.
`EnumeratePortEntriesAsync` returns port identities without reading every
engineering group. Use it to select a port before fetching its values with
`ReadValueSetAsync`; `EnumeratePortsAsync` returns complete port
descriptors.
For the desktop workflow, see [UaLens Pumps](UaLens.md#pumps).
### Creating the client
| Entry point | Use |
| --- | --- |
| `session.Pumps(telemetry)` | Extension on `ISession`; registers the OPC 40223 structured types with the session's encodeable factory |
| `new PumpsClient(session, telemetry)` | The same without the extension |
| `AddPumpsClient()` on `AddClient(...)` | Registers `PumpsClientFactory` and a `Func<CancellationToken, Task<PumpsClient>>` over the managed session, plus the DI client services |
The registered types (`PhysicalAddressDataType` and the five option sets)
decode into the generated classes instead of staying opaque
`ExtensionObject`s.
### Discovery
| Member | Meaning |
| --- | --- |
| `IsSupported` | The server publishes the Pumps namespace |
| `PumpTypeId`, `MachinesFolderId` | Resolved against the session's namespace table, `NodeId.Null` if absent |
| `EnumeratePumpsAsync()` | Every pump below the DI `DeviceSet` and the Machinery `Machines` folder, each returned once |
| `EnumeratePumpsUnderAsync(NodeId root)` | Pumps directly below one folder |
| `DiscoverPumpsAsync()` | The NodeIds of `EnumeratePumpsAsync` |
| `IsPumpAsync(NodeId typeDefinition)` | Whether a type is or derives from `PumpType` |
| `ResolveIdentificationAsync`, `ResolveDiGroupAsync`, `ResolvePumpsChildAsync`, `ResolveOperationalAsync`, `ResolvePortsAsync` | Resolve a group's NodeId for use with `ReadValueSetAsync` |
`Topology` exposes the underlying `DiTopologyClient`.
### Reading groups
| Member | Returns |
| --- | --- |
| `ReadNameplateAsync(pump)` | `PumpNameplate?` |
| `ReadMarkingsAsync(markingsFolder)` | The entries of the nameplate's `Markings` folder (`PumpNameplate.MarkingsFolderId`) |
| `ReadConfigurationAsync(pump, options)` | `PumpConfigurationData?` with `Design`, `Implementation`, `SystemRequirements` |
| `ReadOperationalAsync(pump, options)` | `PumpOperationalData?` with all operational groups and `MultiPump` |
| `ReadMeasurementsAsync`, `ReadSignalsAsync` | One `PumpValueSet` |
| `ReadMultiPumpAsync(pump)` | `MultiPumpConfiguration?` |
| `ReadSupervisionAsync(pump, options)` | `PumpSupervisionStatus?` |
| `ReadMaintenanceAsync(pump, options)` | `PumpMaintenanceData?` |
| `ReadDocumentationAsync(pump)` | The document links as a `PumpValueSet` |
| `EnumeratePortsAsync`, `ReadPortsAsync` | `PumpPortDescriptor`s with their groups |
| `ReadPumpAsync(pump, options)` | `PumpSnapshot`: everything above in one pass |
| `ReadValueSetAsync(group, options)` | Any group by NodeId |
A `null` result means the pump does not publish that group.
### Value sets, not records
The large groups are open sets of optional variables, and a real pump
publishes a handful of them. A record with one property per variable would be
mostly `null` and would need revising with every revision of the
specification. `ReadValueSetAsync` browses the group instead and returns a
`PumpValueSet` filled from what the server actually publishes: three entries
for a pump that publishes three `DesignType` parameters, 78 for one that
publishes all of them.
- Discrete signal objects are followed automatically, and their value is
reported under the object's name (`PumpOperation`, not
`DiscreteInputValue`).
- Other nested OPC 40223 objects are groups of their own and come back in
`PumpValueSet.Groups`, one level deep: the `<Vibration>` instances below
`Measurements`, for example.
```csharp
foreach ((string point, PumpValueSet vibration) in measurements.Groups)
{
double? velocity = vibration.GetDouble(BrowseNames.OverallVibrationVelocityRMS);
}
```
Objects of the base models, such as the `FileType` documents, are left
out; they carry no values.
The closed parts of the specification are records: `PumpNameplate` (the
nameplate list is fixed by OPC 40223) and `MultiPumpConfiguration`, which
maps all eleven `MultiPumpType` variables (§7.34), including the string
arrays `DistributionPriority`, `PumpCollectiveIDs` and `RedundantPumpIDs`.
### Read options
Each `PumpValue` carries the engineering unit and instrument range next to the
reading. An OPC 40223 measurement without its unit cannot be interpreted
(350 kPa and 350 Pa are different machines), and the specification fixes no
unit for its analog variables. That costs one extra round trip per group.
| `PumpReadOptions` | Default | |
| --- | --- | --- |
| `IncludeEngineeringUnits` | `true` | Read `EngineeringUnits` |
| `IncludeRanges` | `true` | Read `EURange` |
| `IncludeTimestamps` | `false` | Return source timestamps |
| `PumpReadOptions.ValuesOnly` | | One browse and one read, no metadata |
Read once with the defaults, retain the metadata, then poll with `ValuesOnly`;
re-read metadata after a device or server configuration change.
### Supervision
`SupervisionType` spreads about 150 boolean fault signals over seven groups.
What a caller usually wants is not one named flag but the answer to "what is
this pump complaining about":
```csharp
PumpSupervisionStatus? supervision = await pumps.ReadSupervisionAsync(pumpId, cancellationToken: ct);
if (supervision?.HasActiveSignals == true)
{
foreach ((string group, PumpValue signal) in supervision.ActiveSignals)
{
Console.WriteLine($"{group}/{signal.Name}");
}
}
```
`ActiveSignals` includes Boolean `true` readings whose status is not bad.
An empty result means no readable signal is raised; signals that are missing,
non-Boolean or bad are not counted.
### Maintenance
`PumpMaintenanceData` exposes the four categories as value sets plus three
derived answers: `StateOfTheItem` and `MaintenanceLevel` (from
`GeneralMaintenance`) and `HasFailure` (from `BreakdownMaintenance`).
## Contracts reference
| Type | Kind | Filled by |
| --- | --- | --- |
| `PumpEntry` | Record: NodeId, browse name, display name, type definition | Discovery |
| `PumpNameplate` | Closed record | `WithNameplate` / `ReadNameplateAsync` |
| `PumpValue` | One reading with status, source timestamp, unit and range; `AsDouble`, `AsBoolean`, `AsString`, `AsStringArray`, `AsUInt32`, `AsEnum<T>`, `AsDateTime` | Value sets |
| `PumpValueSet` | Browse-name-keyed readings; `GetDouble`, `GetBoolean`, `GetString`, `GetStringArray`, `GetUInt32`, `GetDateTime`, `GetEnum<T>`, indexer, `Names`, `Contains`; nested groups in `Groups` / `Group(name)` | All open groups |
| `PumpConfigurationData`, `PumpOperationalData`, `PumpMaintenanceData` | Records of value sets | Group reads |
| `MultiPumpConfiguration` | Record of all eleven `MultiPumpType` variables | `ReadMultiPumpAsync` |
| `PumpSupervisionStatus` | Seven value sets, `ActiveSignals`, `HasActiveSignals` | `ReadSupervisionAsync` |
| `PumpPortDescriptor` | Port kind, direction, category, id carrier, four value sets | Port reads |
| `PumpSnapshot` | Everything above for one pump | `ReadPumpAsync` |
Absent names read as `null`, never as an exception, and a reading with a bad
status reads as no value. Integer readings convert to `double` in `GetDouble`.
## Conformance matrix
Status key: ✅ implemented and tested over a real session ·
🧪 implemented, tested in-process only · 🔧 implemented, not covered by a test ·
❌ not shipped.
### Profiles and conformance units
OPC 40223 1.0 defines ten server conformance units (§9.1, Table 152) and two
server profiles (§9.2, Tables 153–155). It defines no client facets.
| Profile | URI | Status |
| --- | --- | --- |
| Pump Base Server Profile (Table 154) | `http://opcfoundation.org/UA-Profile/Pumps/Server/Base` | Partial: *Base System* and *Pump PumpType Mandatory Nodes* are met, but the mandatory *3:Machine Identification Writable Server Facet* is not, because client writes to the identification are not stored |
| Pump Advanced Server Profile (Table 155) | `http://opcfoundation.org/UA-Profile/Pumps/Server/Advanced` | ❌ Not met: the mandatory alarm, notifier and historical-access facets are not implemented |
`PumpsNodeManager` therefore advertises neither profile URI.
| Conformance unit (Table 152) | Status | Where |
| --- | --- | --- |
| Base System | ✅ | `PumpType` instances, see the server table below |
| Pump PumpType Mandatory Nodes | ✅ | `CreatePumpAsync` materialises every mandatory node |
| Pump Identification | ✅ | `Identification` nameplate |
| PumpClass Data | 🔧 | `PumpClass` in the `Design` group can be set through the group builder; nothing enforces it or tests it |
| Pump Connection Port | ✅ | Inlet and outlet connection ports |
| Pump Drive Port | ✅ | Drive port |
| Pump Control | 🧪 | `PumpActuation`; `Control` is not covered by a test |
| Historizing | ❌ | No historical access for measurements |
| Limit Alarm Status | ❌ | No `LimitAlarmType` alarms |
| Supervision Health Status | ❌ | No `DeviceHealthDiagnosticAlarmType` alarms |
The rows below follow the specification's structure.
### Model
| Area | Status | Source | Tests |
| --- | --- | --- | --- |
| Namespace URI, all 51 object types, 13 enumerations, 5 option sets, `PhysicalAddressDataType` generated | ✅ | [`Model/`](../src/Opc.Ua.Pumps/Model) | `PumpsModelTests` |
| DI group names outside the Pumps `BrowseNames` (`PumpsModel.DiGroups`) | 🧪 | [`PumpsModel`](../src/Opc.Ua.Pumps/PumpsModel.cs) | `PumpsModelTests` |
| Port type classification (`PumpsModel.ClassifyPort`) | 🧪 | same | `PumpsModelTests` |
| Model loading DI → IA → Machinery → Pumps | 🧪 | [`PumpsNodeManager`](../src/Opc.Ua.Pumps.Server/PumpsNodeManager.cs) | `PumpsNodeManagerTests` |
### Server
| Area | Status | Source | Tests |
| --- | --- | --- | --- |
| `PumpType` instance below `DeviceSet` with its own instance NodeIds | ✅ | [`PumpsNodeManager`](../src/Opc.Ua.Pumps.Server/PumpsNodeManager.cs) | `PumpsNodeManagerTests`; `PumpsClientServerE2eTests` |
| Organized from the Machinery `Machines` folder, optional | ✅ | same | `PumpsNodeManagerTests`; `PumpsClientServerE2eTests` |
| Duplicate pump names rejected | 🧪 | same | `PumpsNodeManagerTests` |
| Pump is an event notifier | 🧪 | same | `PumpsNodeManagerTests` |
| Lazy materialisation of groups and leaves | 🧪 | [`PumpGroupBuilder`](../src/Opc.Ua.Pumps.Server/Builders/PumpGroupBuilder.cs) | `PumpBuilderTests` |
| Materialised children carry reference type, type definition, data type and access level | ✅ | same | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| Undeclared browse names rejected | 🧪 | same | `PumpBuilderTests` |
| `Identification` nameplate across DI / Machinery / Pumps | ✅ | [`PumpBuilder`](../src/Opc.Ua.Pumps.Server/Builders/PumpBuilder.cs) | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Configuration`: `Design`, `SystemRequirements` | ✅ | [`PumpGroupBuilder`](../src/Opc.Ua.Pumps.Server/Builders/PumpGroupBuilder.cs) | `PumpsClientServerE2eTests` |
| `Configuration`: `Implementation` | 🔧 | same | — (same code path) |
| `Operational`: `Measurements` with unit and range | ✅ | same | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Operational`: `Signals` (discrete input objects) | ✅ | same | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Operational`: `PumpActuation` (discrete output objects) | 🧪 | same | `PumpBuilderTests` |
| `Operational`: `MultiPump`, all eleven variables | ✅ | same | `PumpsClientServerE2eTests` |
| `Operational`: `Control`, `BypassActuation`, `ThrottleValveActuation` | 🔧 | same | — |
| `Measurements`: `<Vibration>` instances of `VibrationMeasurementType` | ✅ | [`PumpGroupBuilder`](../src/Opc.Ua.Pumps.Server/Builders/PumpGroupBuilder.cs) | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Events`: seven supervision categories | ✅ | same | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Maintenance`: four categories | ✅ | same | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Ports`: drive, inlet and outlet ports with their groups | ✅ | [`PumpBuilder`](../src/Opc.Ua.Pumps.Server/Builders/PumpBuilder.cs) | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `Documentation` links | 🔧 | builder | — |
| `Documentation` `FileType` methods | ❌ | — | Optional (§7.5); attach through `With(...)` |
| `MarkingsType` `<Marking>` files | ❌ | — | Optional; `FileType`, see [Documentation files](#documentation-files) |
| CU *Supervision Health Status*: NE 107 alarms (`2:DeviceHealthDiagnosticAlarmType` subtypes) for supervision signals | ❌ | — | Optional (Table 152/155). OPC 40223 defines no event types of its own; the pump is an event notifier, the alarms are not emitted |
| CU *Limit Alarm Status*: `0:LimitAlarmType` alarms for measurements | ❌ | — | Optional (Table 152/155) |
| Data changes to subscribed clients on runtime updates | ✅ | [`PumpGroupBuilder`](../src/Opc.Ua.Pumps.Server/Builders/PumpGroupBuilder.cs) | `PumpBuilderTests`; `PumpsClientServerE2eTests` |
| `AddPumps` / `ConfigurePumps` hosting | ✅ | [`OpcUaPumpsServerBuilderExtensions`](../src/Opc.Ua.Pumps.Server/Hosting/OpcUaPumpsServerBuilderExtensions.cs) | `PumpsClientServerE2eTests` |
| Rejecting a second DI owner | 🔧 | same | — |
### Client
| Area | Status | Source | Tests |
| --- | --- | --- | --- |
| Discovery from `DeviceSet` and `Machines`, de-duplicated, subtypes included | ✅ | [`PumpsClient`](../src/Opc.Ua.Pumps.Client/PumpsClient.cs) | `PumpsClientServerE2eTests` |
| Nameplate | ✅ | [`PumpsClient.Identification`](../src/Opc.Ua.Pumps.Client/PumpsClient.Identification.cs) | `PumpsClientServerE2eTests` |
| Markings | 🔧 | same | — |
| Configuration, measurements, signals, supervision, maintenance | ✅ | [`PumpsClient.Groups`](../src/Opc.Ua.Pumps.Client/PumpsClient.Groups.cs) | `PumpsClientServerE2eTests` |
| Multi-pump configuration, all eleven variables | ✅ | same | `PumpsClientServerE2eTests` |
| Documentation links | 🔧 | same | — |
| Value sets with units, ranges and discrete objects | ✅ | [`PumpsClient.Values`](../src/Opc.Ua.Pumps.Client/PumpsClient.Values.cs) | `PumpValueSetTests`; `PumpsClientServerE2eTests` |
| Nested OPC 40223 objects (vibration measurements) as `PumpValueSet.Groups` | ✅ | same | `PumpsClientServerE2eTests` |
| Ports with kind and groups | ✅ | [`PumpsClient.Ports`](../src/Opc.Ua.Pumps.Client/PumpsClient.Ports.cs) | `PumpsClientServerE2eTests` |
| Whole-pump snapshot | ✅ | [`PumpsClient.Snapshot`](../src/Opc.Ua.Pumps.Client/PumpsClient.Snapshot.cs) | `PumpsClientServerE2eTests` |
| Supervision evaluation (`ActiveSignals`) | 🧪 | [`PumpSupervisionStatus`](../src/Opc.Ua.Pumps/Contracts/PumpSupervisionStatus.cs) | `PumpValueSetTests` |
| `AddPumpsClient` hosting | 🔧 | [`OpcUaPumpsClientBuilderExtensions`](../src/Opc.Ua.Pumps.Client/Hosting/OpcUaPumpsClientBuilderExtensions.cs) | — |
## Samples
`PumpsServer` accepts `--host`, `--port`, `--pki-root`, `--autoaccept` and
`--run-seconds`. The PKI path must be absolute; a supplied runtime must be from
1 through 3600 seconds. Without the runtime option, the server runs until
stopped interactively. Certificate auto-accept is enabled by default for sample
convenience; disable it when testing explicit peer trust.
UaLens launches its owned simulator on loopback with private PKI,
`--autoaccept false` and an explicit finite runtime.
| Sample | Shows |
| --- | --- |
| [PumpsServer](../samples/Pumps/PumpsServer) | `AddPumps` / `ConfigurePumps` with two pumps: nameplate, every group builder, units and ranges, ports, documentation links, and a simulation that publishes runtime updates to subscribers |
| [PumpsClient](../samples/Pumps/PumpsClient) | `PumpsClient` discovery, `ReadPumpAsync` snapshots, supervision and maintenance evaluation, `ValuesOnly` polling, and streaming subscriptions on `PumpValue.NodeId` |
| [PumpDeviceIntegrationServer](../samples/Di/PumpDeviceIntegrationServer) | A datasheet-driven pump simulation in its own `DiNodeManager` subclass with the Pumps model loaded next to OpenUSD, published data changes, and an OpenUSD twin per pump |
| [WoT aggregation](../samples/WotCon/README.md) | A DI / Machinery / Pumps `PumpType` instance loaded at runtime from WoT documents, with no Pumps assembly referenced |
| [SiteCompositionServer](../samples/OpenUsd/SiteCompositionServer) | A supervisory server composing the pump and generator servers into one scene |
## Testing
```bash
dotnet test tests/Opc.Ua.Pumps.Tests
```
| Fixture | Covers |
| --- | --- |
| `PumpsModelTests` | The generated model against the specification |
| `PumpsNodeManagerTests` | Model loading, pump creation, folders, NodeIds |
| `PumpBuilderTests` | Every builder shape in-process |
| `PumpValueSetTests` | Value-set and supervision evaluation |
| `PumpsCompositionTests` | Stock Machinery owner, ordered providers, lazy groups, instance ownership, cancellation and runtime disposal |
| `PumpsClientServerE2eTests` | Standalone and composed Machinery hosts, read back by `PumpsClient` over real sessions and live data-change subscriptions |
The end-to-end test is the one that matters for anything a client sees. An
in-process assertion on the typed model passes even when a client sees
nothing, as the [generator gap](#generator-gap-this-model-uncovered) below
shows.
## Troubleshooting
| Symptom | Cause | Fix |
| --- | --- | --- |
| `CS0104: 'BrowseNames' is an ambiguous reference` | `using Opc.Ua;` and `using Opc.Ua.Pumps;` both bring a `BrowseNames` class (and `System` a `Range`) | Alias them: `using BrowseNames = Opc.Ua.Pumps.BrowseNames;`, `using Range = Opc.Ua.Range;` |
| `ArgumentException: '…' declares no child named '…'` | The browse name is not declared by that group, or belongs to another group | Use a generated `BrowseNames` constant on the group that declares it (e.g. `Cavitation` is in `SupervisionProcessFluid`, not `Measurements`) |
| Browse path to `Identification`/`Configuration`/`Operational`/`Maintenance` does not resolve | Qualified with the Pumps namespace | Use the DI namespace index; see [The model](#the-model) |
| `InvalidOperationException`: DI address space already owned | `AddPumps` combined with `AddOpcUaDi` or another DI owner | [Load the model into the existing manager](#coexisting-with-a-server-that-already-owns-di) |
| `BadBrowseNameDuplicated` from `CreatePumpAsync` | A pump of that name already exists below the parent | Use unique names, or `manager.Pump(nodeId)` to reconfigure |
| Machinery clients don't find the pumps | `OrganizeIntoMachinesFolder = false` | Leave it on |
| Machinery model fails to load | `LoadIndustrialAutomationModel = false` | Leave it on |
| `ArgumentException: … declares '<Vibration>' as a placeholder` | `Nested("Vibration")` on a placeholder | Use `AddVibration(name)` |
| A measurement has no unit on the client | Server set it without `EngineeringUnits`, or client read with `ValuesOnly` | Pass the unit to `SetAnalog`; read metadata once with the defaults |
| `PhysicalAddress` or option sets arrive as `ExtensionObject` | Client not created through `session.Pumps(...)` | Use the extension |
| A vendor port comes back as `PumpPortKind.Unknown` | Vendor subtype of `PortType` | Expected; its groups are still read |
## Model provenance
`src/Opc.Ua.Pumps/Model/Opc.Ua.Pumps.NodeSet2.xml` is the unmodified OPC
Foundation publication from
[`UA-Nodeset/latest/Pumps`](https://github.com/OPCFoundation/UA-Nodeset/tree/latest/Pumps):
model version 1.0.0, published 2021-04-19, requiring UA 1.04.7, DI 1.02.2 and
Machinery 1.0.0. The sibling `Opc.Ua.Pumps.NodeSet2.csv` pins the
symbol-to-NodeId mapping, so a client that caches NodeIds across builds keeps
working; the generator fails the build when the two disagree.
The `RequiredModel` versions are a lower bound, not a pin. The repository's
DI and Machinery models are newer and satisfy them.
## Generator gap this model uncovered
The generated state classes implement
`NodeState.CreateChild(context, browseName)` for every optional child the
model declares. One builder can therefore cover all ~3,000 of them. That
generic path creates a child of the right CLR type and browse name, but does
**not** stamp the modelling metadata: the child comes back with no
`ReferenceTypeId`, no `TypeDefinitionId` and `BaseDataType` as its data type.
(The per-child `AddXxx(context)` helpers go through the model factory and do
set all of it.)
Such a node looks fine from inside the server and can even be read by NodeId,
but it is **invisible to a client**: every browse a client makes is filtered
on `HierarchicalReferences`, and the child has no reference type to match.
The same applies to `TranslateBrowsePathsToNodeIds`.
`PumpGroupBuilder` therefore completes each newly created child from its
instance declaration on the type, copying `ReferenceTypeId`,
`TypeDefinitionId`, `DataType`, `ValueRank`, access levels and sampling
interval before registering it, and filling only members the generic path
left unset. `PumpsClientServerE2eTests` catches regressions here.
## See also
- [Device Integration developer guide](DeviceIntegration.md): `DiNodeManager`,
`ConfigureDevicesFor`, the DI client
- [Machinery developer guide](Machinery.md): the `Machines` folder and the
OPC 40001-1 building blocks `PumpType` inherits
- [Subscriptions](Subscriptions.md): monitored items and data-change delivery
- [OPC 40223 on the OPC Foundation reference site](https://reference.opcfoundation.org/specs/OPC-40223)