This pull request improves the registration of property nodes created via the fluent API, ensuring that properties such as `EngineeringUnits` and `EURange` are always properly registered with the node manager. It introduces a utility class to centralize node registration and updates both the implementation and tests to reflect this behavior. **Fluent API improvements:** * Refactored `EnsureEngineeringUnitsProperty` and `EnsureEURangeProperty` to always register created properties using the new `FluentNodeRegistration.RegisterCreatedNode` method, ensuring consistent node manager registration. [[1]](diffhunk://#diff-4128c152ec415df1746b49a2e1df0e18cb05e5381c3e5884c6724a0e2c2f1b61L92-R92) [[2]](diffhunk://#diff-4128c152ec415df1746b49a2e1df0e18cb05e5381c3e5884c6724a0e2c2f1b61L132-R132) [[3]](diffhunk://#diff-4128c152ec415df1746b49a2e1df0e18cb05e5381c3e5884c6724a0e2c2f1b61L169-R191) * Updated property creation in `PropertyInitBuilderExtensions.cs` to use the centralized registration utility. **New utility for node registration:** * Added the `FluentNodeRegistration` class to encapsulate logic for registering newly created nodes with an `AsyncCustomNodeManager`. **Testing improvements:** * Added new tests to verify that `EngineeringUnits` and `EURange` properties are both browsable and readable, and that they are properly registered with the node manager. [[1]](diffhunk://#diff-84c2ea5e8e977638cf52c62a81a90c62f67b2d78efd70db62b3ef39c2f86adc4R136-R198) [[2]](diffhunk://#diff-84c2ea5e8e977638cf52c62a81a90c62f67b2d78efd70db62b3ef39c2f86adc4R281-R331) [[3]](diffhunk://#diff-8ff5510d21a0f088a52b9f7be699cc08a4033e9514d2fb491bdfaa55e2d50b27R88-R157) * Refactored and renamed test files and classes for clarity, updating test harnesses and node manager mocks to support the new registration logic. [[1]](diffhunk://#diff-8ff5510d21a0f088a52b9f7be699cc08a4033e9514d2fb491bdfaa55e2d50b27L32-R33) [[2]](diffhunk://#diff-8ff5510d21a0f088a52b9f7be699cc08a4033e9514d2fb491bdfaa55e2d50b27L42-R50) [[3]](diffhunk://#diff-8ff5510d21a0f088a52b9f7be699cc08a4033e9514d2fb491bdfaa55e2d50b27L118-R187) [[4]](diffhunk://#diff-8ff5510d21a0f088a52b9f7be699cc08a4033e9514d2fb491bdfaa55e2d50b27R197-R248) [[5]](diffhunk://#diff-8ff5510d21a0f088a52b9f7be699cc08a4033e9514d2fb491bdfaa55e2d50b27R263-R268)# Description _Describe the changes here to communicate to the maintainers why they should accept this pull request. By default - this will become the Commit message after merging and thus define history._ ## Related Issues _Reference all GitHub issues this PR addresses. If there is no issue yet, open one and link it here._ _If this is a relatively large or complex change, a design must have been discussed in the related tracking issue and signed off (which becomes the Architectural Decision Record (ADR))._ - Fixes #github-issue-number, ... ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [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. - [ ] 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. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Marc Schier <marcschier@users.noreply.github.com> Co-authored-by: Marc Schier <marcschier@hotmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PumpDeviceIntegrationServer
A self-contained, NativeAOT-friendly OPC UA server that demonstrates
the OPC 40223 Pumps companion specification
with a full live simulation, wired through the fluent
INodeManagerBuilder API and the additive OPC 10000-100 topology-element
builder integration.
The pump sample is the integration test for every fluent API extension
shipped under src/Opc.Ua.Server/Fluent/. Each extension is
documented in
Source-generated NodeManagers — Building richer node managers.
Running the sample
cd samples/PumpDeviceIntegrationServer
dotnet run -c Release
The server listens on opc.tcp://localhost:62542/PumpDeviceIntegrationServer
by default. Override with --port 62550.
Sample console output:
info: Opc.Ua.Server.MasterNodeManager
MasterNodeManager.Startup - NodeManagers=3
info: Pumps.PumpNodeManager
Configuring PumpNodeManager fluent wiring...
info: Pumps.PumpNodeManager
PumpNodeManager: address space ready (10330 predefined nodes).
info: Opc.Ua.Server.StandardServer
OPC UA server listening at opc.tcp://localhost:62542/PumpDeviceIntegrationServer.
Browse to Objects > DeviceSet > Pump #1 in any OPC UA client (e.g.
UaExpert) to explore the simulated pump. A second declarative pump,
Pump #2, is organized alongside it by the same DeviceSet — it
demonstrates the DI hosting ConfigureDevicesFor flow without the
hand-wired fluent simulation.
Running in Docker
A Dockerfile is provided that builds the Release
publish output on the .NET AzureLinux 3 base images and runs it as a
non-root user.
Build from the repository root, not from this folder. The image needs the full source tree (
src/,src/,tools/), so the Docker build context must be the repo root and the Dockerfile is selected with-f. Runningdocker build .from inside this folder fails fast with a message telling you the correct command.
# from the repository root:
docker build -f samples/PumpDeviceIntegrationServer/Dockerfile `
-t pumpdeviceintegrationserver:local .
Run it, publishing the OPC UA port:
docker run --rm -p 62542:62542 pumpdeviceintegrationserver:local
Inside the container the endpoint binds to 0.0.0.0 so it is reachable
from the host. Override the bind host and port via environment variables:
docker run --rm -p 62550:62550 `
-e host=0.0.0.0 -e port=62550 `
pumpdeviceintegrationserver:local
The server creates its certificate/PKI store under /app at runtime.
To persist certificates across container restarts, mount a volume:
docker run --rm -p 62542:62542 `
-v pump-pki:/app/pki `
pumpdeviceintegrationserver:local
The image is built and published to the GitHub Container Registry by the
pump-device-integration-server-docker.yml
workflow on every push to master and on manual dispatch.
What the sample demonstrates
| Feature | Where |
|---|---|
AddOpcUa().AddServer(...).AddNodeManager<T>() hosting |
Program.cs |
| Multi-model composition (DI library + locally source-generated Machinery + Pumps) | PumpNodeManager.cs LoadPredefinedNodesAsync |
Identification properties via WithProperty(name, value) |
PumpNodeManager.Configure.cs WithIdentification |
Optional-child materialisation via generator-emitted AddXxx(context) helpers (Operational / Measurements / Events / SupervisionProcessFluid / SupervisionPumpOperation / Maintenance) |
PumpNodeManager.cs MaterialisePumpOptionalChildren |
Engineering units / EURange via WithEngineeringUnits / WithEURange |
WithMeasurements |
Discrete NumberOfStarts counter wired via Variable<uint>(...).OnRead(...) |
WithMeasurements |
250 ms simulation tick via Simulation(...).OnTick(...) |
Configure → AdvanceSimulation |
Limit alarm with thresholds and acknowledge handler via CreateLimitAlarm(...).WithLimits(...) |
WithSupervision |
Boolean supervision (TwoStateDiscreteState) → alarm activation via .ActivatesAlarm(...) |
WithSupervision |
| Cross-namespace path resolution (Pump #1 in Pumps NS → Operational in Machinery NS → Measurements in Pumps NS, all in one unqualified browse path) | src/Opc.Ua.Server/Fluent/BrowsePathResolver.cs |
Generated PumpType instance + typed Identification group configuration |
Program.cs (Pump #2) |
Architecture
PumpDeviceIntegrationServer/
├── Program.cs # AddOpcUa().AddServer(...).AddNodeManager<T>()
│ # + ConfigureDevicesFor declarative Pump #2
├── PumpNodeManager.cs # Hand-written FluentNodeManagerBase
│ # + LoadPredefinedNodesAsync (multi-model)
│ # + CreateAddressSpaceAsync (builder setup)
├── PumpNodeManager.Configure.cs # partial — fluent wiring + simulation tick
├── PumpDeviceIntegrationServer.csproj # ProjectReference to Opc.Ua.Di model lib
│ # AdditionalFiles for Machinery + Pumps
│ # NodeSet2 (consumed by source generator)
├── Model/
│ ├── Opc.Ua.Machinery.NodeSet2.xml # AdditionalFiles — build-time only
│ └── Opc.Ua.Pumps.NodeSet2.xml # AdditionalFiles — build-time only
└── Properties/AssemblyInfo.cs
The Opc.Ua.Di model library is consumed as a project reference (its
types live under the Opc.Ua.Di namespace and are source-generated
from the ModelDesign XML). Cross-namespace references from Machinery
and Pumps to DI types resolve through the
[assembly: ModelDependencyAttribute] carried in the Opc.Ua.Di
assembly — no DI NodeSet2 XML needed in this project. The unified
attribute carries the compact type-table payload that the consumer's
source generator imports at compile time; see
ModelDependencies.md for the wire
format and consumer-side flow.
The Machinery and Pumps NodeSet2 XMLs are source-generated locally
inside this assembly via the <AdditionalFiles> plumbing in the
.csproj. The generator emits typed *State classes, NodeId tables,
and the AddOpcUaMachinery / AddOpcUaPumps extension methods that
LoadPredefinedNodesAsync calls. No runtime XML loading happens — the
Model/ folder is a build-time input only. Consumer assemblies that
want to reference Machinery or Pumps the same way they reference
Opc.Ua.Di should source-generate against the model XML inside their
own assembly using the same <AdditionalFiles> pattern.
Extending the sample
- Add a measurement: open
PumpNodeManager.Configure.cs, add a call toAddMeasurement(builder, browsePath, getter, units, min, max)insideWithMeasurements, then add a field + line toAdvanceSimulationthat updates the value each tick. - Add an alarm: inside
WithSupervision, chain anotherbuilder.Node("Pump #1/Events").CreateLimitAlarm(...).WithLimits(...)and wire the triggering boolean variable via.ActivatesAlarm(...). - Add a second pump: two patterns are demonstrated in the sample.
- Hand-rolled (used for
Pump #1): inPumpNodeManager.CreatePumpAsync, create the generatedPumpState, attach it to the DIDeviceSetwithOrganizes, and register it. The fluentConfigure.csthen wires its measurements, alarms, and simulation by browse path. - DI declarative (used for
Pump #2): inProgram.cs, callPumpNodeManager.CreatePumpAsync(...)from aConfigureDevicesFor<PumpNodeManager>block, wrap the generatedPumpStatewithctx.TopologyElement<PumpState>(...), then configure the mandatoryIdentificationgroup. This preserves thePumpTypetype definition while exposing only topology-element operations.
- Hand-rolled (used for
NativeAOT publishing
cd samples/PumpDeviceIntegrationServer
dotnet publish -c Release -r win-x64
The pump server publishes cleanly under NativeAOT — no trim or AOT warnings — because every fluent extension is reflection-free and the generated model factories are statically rooted.
See also
docs/DeviceIntegration.md— full developer guide for the DI library trio (device builder, hosting integration, lock service, software-update package store, client helpers).