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

1264 lines
75 KiB
Markdown

# OPC UA for Machinery (OPC 40001)
Released [Machine Vision (OPC 40100-1/-2)](Vision.md) composes into this
manager through `AddVisionToMachinery()` and `ConfigureVisionFor<MachineryNodeManager>()`.
This reuses the existing DI/Machinery owner; do not register a second asset-owning Vision manager.
[AutoID readers (OPC 30010)](AutoID.md#coexisting-with-a-server-that-already-owns-di) compose
the same way through `AddAutoIdModel()` and `ConfigureAutoIdFor<MachineryNodeManager>()`.
The `Opc.Ua.Machinery*` packages implement the OPC 40001 specification series —
the building blocks every machine shares, plus the four specialised parts that
production equipment adds depending on its use case.
## Contents
- [Library layout](#library-layout)
- [Models and namespaces](#models-and-namespaces)
- [Dependency specifications](#dependency-specifications)
- [Quick start — server](#quick-start--server)
- [Quick start — client](#quick-start--client)
- [Server hosting model](#server-hosting-model)
- [Coexisting with a server that already owns DI or ISA-95](#coexisting-with-a-server-that-already-owns-di-or-isa-95)
- [Bindings on an externally registered root](#bindings-on-an-externally-registered-root)
- [Builder reference](#builder-reference)
- [OPC 40001-1 building blocks](#opc-40001-1-building-blocks)
- [OPC 40001-2 process values](#opc-40001-2-process-values)
- [OPC 40001-3 job management](#opc-40001-3-job-management)
- [OPC 40001-4 energy](#opc-40001-4-energy)
- [OPC 40001-101 results](#opc-40001-101-results)
- [Abstract event types](#abstract-event-types)
- [State machines are server-driven](#state-machines-are-server-driven)
- [Client](#client)
- [Bounded, evidenced inspection](#bounded-evidenced-inspection)
- [Job endpoints and streamed results](#job-endpoints-and-streamed-results)
- [Conformance matrix](#conformance-matrix)
- [OPC 40001-1 building blocks](#opc-40001-1-building-blocks-1)
- [OPC 40001-2 process values](#opc-40001-2-process-values-1)
- [OPC 40001-3 job management](#opc-40001-3-job-management-1)
- [OPC 40001-4 energy](#opc-40001-4-energy-1)
- [OPC 40001-101 results](#opc-40001-101-results-1)
- [Client and hosting](#client-and-hosting)
- [Where the facet URIs come from](#where-the-facet-uris-come-from)
- [Model sources and identifier tables](#model-sources-and-identifier-tables)
- [What the identifier table is and is not](#what-the-identifier-table-is-and-is-not)
- [Why the published tables need deriving](#why-the-published-tables-need-deriving)
- [Cross-assembly generator behavior](#cross-assembly-generator-behavior)
- [See also](#see-also)
## Library layout
Each part is its own model assembly, so a server takes only what it serves. A
machine tool that reports jobs and results does not have to carry PADIM.
| Package | Specification | Model URI |
| --- | --- | --- |
| `Opc.Ua.Machinery` | OPC 40001-1 Basic Building Blocks | `http://opcfoundation.org/UA/Machinery/` |
| `Opc.Ua.Machinery.ProcessValues` | OPC 40001-2 Process Values | `…/UA/Machinery/ProcessValues/` |
| `Opc.Ua.Machinery.Jobs` | OPC 40001-3 Job Management | `…/UA/Machinery/Jobs/` |
| `Opc.Ua.Machinery.Energy` | OPC 40001-4 Energy Management | `…/UA/Machinery/Energy/` |
| `Opc.Ua.Machinery.Result` | OPC 40001-101 Result Transfer | `…/UA/Machinery/Result/` |
Two more packages turn those models into a running server and a client:
| Package | What it adds |
| --- | --- |
| `Opc.Ua.Machinery.Server` | `MachineryNodeManager`, the fluent machine builder, the state-machine controllers, the OPC 40001-101 download path, and the `AddMachinery()` hosting pipeline |
| `Opc.Ua.Machinery.Client` | `MachineryClient` — discovery from the `Machines` folder, identification, state observation, job control and result download |
## Models and namespaces
The generated C# namespace of each model is derived from its model URI by
`NodeSetToModelDesign.CreateNamespace`, and every project pins the derived value
explicitly — it is part of the contract each assembly publishes through
`[assembly: ModelDependencyAttribute]` (see [ModelDependencies.md](ModelDependencies.md)).
| Model URI | C# namespace | `Namespaces` member | Model loader |
| --- | --- | --- | --- |
| `…/UA/Machinery/` | `Opc.Ua.Machinery` | `Machinery` | `AddOpcUaMachinery` |
| `…/UA/Machinery/ProcessValues/` | `Opc.Ua.Machinery.ProcessValues` | `MachineryProcessValues` | `AddOpcUaMachineryProcessValues` |
| `…/UA/Machinery/Jobs/` | `Opc.Ua.Machinery.Jobs` | `MachineryJobs` | `AddOpcUaMachineryJobs` |
| `…/UA/Machinery/Energy/` | `Opc.Ua.Machinery.Energy` | `MachineryEnergy` | `AddOpcUaMachineryEnergy` |
| `…/UA/Machinery/Result/` | `Opc.Ua.Machinery.Result` | `MachineryResult` | `AddOpcUaMachineryResult` |
| `…/UA/IA/` | `Opc.Ua.IA` | `IA` | `AddOpcUaIA` |
| `…/UA/ECM/` | `Opc.Ua.ECM` | `ECM` | `AddOpcUaECM` |
| `…/UA/PADIM/` | `Opc.Ua.PADIM` | `PADIM` | `AddOpcUaPADIM` |
| `…/UA/Dictionary/IRDI` | `Opc.Ua.IRDI` | `IRDI` | `AddOpcUaIRDI` |
Note on placement: the `Machines` folder (`FolderType`) that OPC 40001-1 defines
is `Organizes`-referenced from `Objects`, **not** from the DI `DeviceSet`. There
is no `MachineryItems` folder in 1.04.1, and no `MachineryBuildingBlocksType` —
the building blocks are AddIn instances (`Identification`, `Monitoring`,
`Components`, `MachineryEquipment`, `Notifications`).
## Dependency specifications
The Machinery models compose types from these companion specifications:
| Package | Specification | Why |
| --- | --- | --- |
| `Opc.Ua.IA` | OPC 10000-200 Industrial Automation | 40001-1 types `MonitoringType.Status.Stacklight` with IA's `BasicStacklightType`. Machinery and Robotics reference this shared model package, avoiding duplicate producers for the same model URI and C# prefix. Its generated types use the `Opc.Ua.IA` namespace and `AddOpcUaIA` registration. |
| `Opc.Ua.PADIM` | OPC 30081 PADIM + the IRDI dictionary | 40001-2 derives `ProcessValueType` / `ProcessValueVariableType` from PADIM's `AnalogSignalType` / `AnalogSignalVariableType`. IRDI ships in the same assembly because PADIM references it several hundred times and is unusable without it. |
| `Opc.Ua.ECM` | OPC 34100 Energy Consumption Management | 40001-4's energy carriers are instances of ECM's `EnergyMeasurementValueType`. |
| `Opc.Ua.ISA95` | OPC 10031-4 ISA-95 Job Control V2 | 40001-3's `JobManagementType` composes the Job Control V2 receiver and response provider. |
The model reference graph:
```mermaid
flowchart LR
core["Opc.Ua.Core"] --> di["Opc.Ua.Di"]
di --> ia["Opc.Ua.IA"]
ia --> robotics["Opc.Ua.Robotics"]
ia --> ecm["Opc.Ua.ECM"] --> energy["Opc.Ua.Machinery.Energy"]
ia --> machinery["Opc.Ua.Machinery"]
di --> padim["Opc.Ua.PADIM (+ IRDI)"] --> values["Opc.Ua.Machinery.ProcessValues"]
core --> result["Opc.Ua.Machinery.Result"]
isa["Opc.Ua.ISA95"] --> jobs["Opc.Ua.Machinery.Jobs"]
```
## Quick start — server
```csharp
builder.Services
.AddOpcUa()
.AddServer(options =>
{
options.ApplicationName = "MachineryServer";
options.ApplicationUri = "urn:localhost:OPCFoundation:MachineryServer";
options.EndpointUrls.Add("opc.tcp://localhost:62546/MachineryServer");
})
.AddMachinery(options => options.Parts = MachineryParts.All)
.ConfigureMachinery(async machinery =>
{
IMachineHandle<BaseObjectState> press = await machinery
.AddMachine("Press-1")
.WithIdentification(id =>
{
id.Manufacturer = new LocalizedText("Acme");
id.Model = new LocalizedText("P-500");
id.SerialNumber = "SN-0001";
id.ProductInstanceUri = "urn:acme:press:1";
})
.WithMonitoring(monitoring => monitoring
.WithMachineryItemState()
.WithOperationMode())
.BuildAsync();
// The handle is how the application drives the machine afterwards.
await press.ItemState!.SetStateAsync(MachineryItemStateValue.NotExecuting);
});
```
Nothing reaches the address space until `BuildAsync` runs, and a failure
anywhere in the chain rolls the machine back — the server is never left with
half a machine.
`MachineryParts` decides which models the manager loads, which namespaces it
announces and which conformance units it can advertise. Trim it so the server
carries only what it serves:
| Value | Needs |
| --- | --- |
| `BuildingBlocks` | DI + IA |
| `ProcessValues` | `BuildingBlocks` + PADIM + IRDI |
| `Jobs` | ISA-95 Job Control V2 only — **no DI** |
| `Energy` | `BuildingBlocks` + ECM |
| `Result` | UA core only — **no DI, no machine model** |
A server that publishes nothing but measurement results skips the machine model
entirely:
```csharp
builder.Services
.AddOpcUa()
.AddServer(/* … */)
.AddMachineryResults(options => options.RootBrowseName = "ResultManagement");
```
That registers [`MachineryResultNodeManager`](../src/Opc.Ua.Machinery.Server/MachineryResultNodeManager.cs),
which shares its method binder with the `ResultManagement` object below a
machine, so both answer a client identically.
## Quick start — client
```csharp
MachineryClient machinery = session.Machinery(telemetry);
await foreach (MachineEntry machine in machinery.EnumerateMachinesAsync(ct))
{
MachineIdentification? id = await machinery
.ReadIdentificationAsync(machine.NodeId, ct);
FiniteStateSnapshot? state = await machinery
.GetItemStateAsync(machine.NodeId, ct);
Console.WriteLine(
$"{id?.Manufacturer.Text} {id?.Model.Text} — {state?.CurrentState.Text}");
}
```
With the constructor that takes no options, optional-block accessors return
`null` when a machine does not publish the block: OPC 40001-1 makes nearly
everything optional, so a client that insists on a block would work against
one vendor and fail against the next.
## Server hosting model
`AddMachinery()` registers `MachineryNodeManager`, a `DiNodeManager` subclass —
the same shape `RoboticsNodeManager` has. One manager serves every configured
part, because the parts share one instance tree: a machine's process values,
jobs, energy carriers and results all hang off the same object.
`ConfigureMachinery(...)` runs during server startup, after the models are
loaded and the address space is wired. Three overloads take a delegate
(sync, async, async with token) and one takes an `IMachineryConfigurator`
class, which is the one to reach for when the configuration needs services
from the container.
Instances are minted into an application-owned namespace,
`MachineryServerOptions.InstanceNamespaceUri`
(default `urn:opcua-netstandard:machinery:instances`). It must not be a model
namespace, and the options validate that.
## Coexisting with a server that already owns DI or ISA-95
`AddMachinery()` claims the Device Integration address space, and — when the
`Jobs` part is selected — the ISA-95 namespaces as well. A second registration
that would load the same models fails immediately with a message naming the
current owner, rather than producing a server with two `DeviceSet` trees.
A server that already owns either one loads the Machinery models into its own
manager and drives them through the same builder:
```csharp
protected override ValueTask<NodeStateCollection> LoadPredefinedNodesAsync(
ISystemContext context, CancellationToken ct = default)
{
var nodes = new NodeStateCollection();
nodes.AddMachineryTypeSystem(context, MachineryParts.BuildingBlocks);
nodes.AddOpcUaPumps(context);
return new ValueTask<NodeStateCollection>(nodes);
}
```
```csharp
builder.ConfigureMachineryFor<PumpNodeManager>(async machinery =>
{
await machinery.AddMachine(pump, new QualifiedName("Pump-1"))
.WithIdentification(/* … */)
.BuildAsync();
});
```
`AddMachine<TState>(TState machine, …)` adopts an instance a companion
specification created — an OPC 40223 `PumpState`, an OPC 40223-style
`GeneratorSetState` — so the vendor model keeps its own type definition and
still answers the Machinery browse paths. The custom manager only has to expose
an `IMachineryNodeIdFactory` or the shared DI `ICompanionNodeIdFactory`;
`MachineryNodeManager` implements both. A custom manager supplies a thread-safe
allocator for the configured instance namespace. The Robotics allocator marker
also derives from the shared marker, allowing both families to reuse one owner
when their required models are loaded and their instance namespaces agree.
The optional `ICompanionNodeIdReservationOwner` capability releases temporary
reservations through the actual allocator on success or rollback; both stock
managers implement this capability separately from their allocator markers.
When a companion already declares its nameplate, bind it with
`machine.WithIdentification(existingIdentification, configure)` instead of
creating another base add-in. Derived machine identification and nested
component identification are both supported. The type, parent, browse name,
NodeId and reference type remain intact, and `MachineryBuildingBlocks` points
at that same node. Required identification values are validated before the
existing nameplate is changed; machine and component requirements remain
distinct.
### Bindings on an externally registered root
When Robotics owns a companion root under `DeviceSet`, do not also call
`AddMachine(root).BuildAsync()`: that adopts the root as a Machinery-owned
machine. Instead enlist shared bindings in the Robotics registration:
```csharp
system.WithRegistrationParticipant(machineryContext.BindMachine(
craneState,
machine => machine.WithIdentification(craneState.Identification!, identity =>
{
identity.Manufacturer = new LocalizedText("Example manufacturer");
identity.SerialNumber = "CR-1";
identity.ProductInstanceUri = "urn:example:crane:CR-1";
})));
```
`system` is the builder supplied to `AddMotionDeviceSystemAsync<TState>`.
Both contexts must use the same node manager and application namespace.
The binding callback runs before publication and reuses the normal Machinery
writer; it must not call `BuildAsync` or change the root's identity.
Robotics validates and registers the entire subtree once. Machinery adds
`Machines` organizing references without changing the root's `DeviceSet`
parent or `HasComponent` reference. Facets commit only after every participant
has bound successfully. Failure or cancellation releases the participant's
references, resources and reservations, while only the registration owner
removes its own attempted tree.
The DI-level `ICompanionRegistrationParticipant` is single-use. Its
`PrepareAsync` and `BindAsync` phases may fail; `Commit` must not perform I/O
or throw. The owner disposes participants in reverse order on every path.
Committed Machinery bindings use the existing node-attachment lifetime,
including on an existing Robotics manager: deleting the root or stopping the
manager releases its bindings without a second owner or manager-wide registry.
For either an ordinary machine builder or an enlisted one,
`WithRegistrationBinding(async (state, context, cancellationToken) => resource)`
defers an asynchronous binding until after indexing. The returned
`IAsyncDisposable` is owned by the transaction: a later failure disposes it,
and success transfers it to the existing machine lifetime. If configuration
throws before an ordinary `BuildAsync` is called, await `builder.AbortAsync()`
to release the unused name and allocations. Aborting cannot remove a
registered machine and permanently closes that builder.
For ordinary machine builders, successful registration groups the owned
resources behind the exact root node. Deleting that root awaits reverse-order
cleanup without touching another machine's resources. Manager shutdown joins
any pending cleanup and releases remaining roots; a removed root does not
retain an ever-growing entry in the coordinator. This uses the manager's
actual asynchronous node-removal hook, not a value change-mask callback.
## Builder reference
### OPC 40001-1 building blocks
Every block is attached to its machinery item with `HasAddIn`, as specified by
OPC 40001-1 — not `HasComponent`.
For a companion-created or vendor-derived Components AddIn,
`machine.WithComponents(existingComponents, configure)` reuses the existing
`MachineComponentsState`. Its parent, qualified name, NodeId, reference and
type definition are preserved. The node must be a descendant of this machine,
derive from `MachineComponentsType`, and retain a `HasAddIn` reference or
subtype. Existing child names are reserved rather than silently replaced.
| Call | What it adds |
| --- | --- |
| `WithIdentification(…)` | `Identification`, typed `MachineIdentificationType`. The browse name comes from the DI namespace because the type derives from DI's `FunctionalGroupType`. `Manufacturer`, `SerialNumber` and (for a machine) `ProductInstanceUri` are mandatory and validated. |
| `WithMonitoring(…)` | `Monitoring`, and below it `Status/MachineryItemState`, `Status/MachineryOperationMode`, `Status/Stacklight` (OPC 10000-200 IA), `Health`, `Process`, `Consumption` — each only when asked for |
| `WithComponents(…)` | `Components`, and one `AddComponent` per machine component. A component gets the narrower `MachineryComponentIdentificationType`. |
| `WithMachineryEquipment(…)` | `MachineryEquipment` and the equipment below it. `MachineryEquipmentTypeId` is mandatory and required by the call; `WithEquipmentLife(…)` adds the DI `LifetimeVariableType` the equipment interface declares. |
| `WithNotifications(…)` | `Notifications`, with `EventNotifier` set, registered as a root notifier, and a publisher the application reports its own events on |
| `WithOperationCounters(…)` | `OperationCounters` — `PowerOnDuration`, `OperationDuration`, `OperationCycleCounter` |
| `WithLifetimeCounters(…)` | `LifetimeCounters` and one DI `LifetimeVariableType` per `AddLifetimeVariable` |
#### The `MachineryBuildingBlocks` organizer
OPC 40001-1 §7.1 puts the building blocks below a `FolderType` object named
`MachineryBuildingBlocks` (Machinery namespace), referenced from the machinery
item with `HasComponent`, and the blocks themselves with `HasAddIn` from that
folder. Table 12 of the specification says the item state, the operation mode,
both counters, monitoring, equipment and notifications **shall** be reachable
that way, and identification and the components folder **may** be.
This is not presentation. Every conformance unit for those blocks is worded
*"has this AddIn under its MachineryBuildingBlocks folder"*, and OPC 40001-3's
`Machinery Job Management Base` is worded the same way — so a server without
the folder satisfies none of them. The builder creates it on first use and
references every block it builds; §7.3 forbids duplicating the node, so the
second path is a reference to the same node, not a copy. The blocks keep their
direct `HasAddIn` from the machinery item as well, which §7.1 recommends as the
top-level entry point.
There is no `MachineryItems` folder and no `MachineryBuildingBlocksType` in
1.04.1 — both appear in older drafts and in reduced NodeSet copies. The folder
has no type of its own: it is a plain `FolderType` identified by its browse
name, which is why the builder assembles it rather than a generated factory.
The `Machines` folder is `Organizes`-referenced from `Objects`.
#### Default instance browse names are load-bearing
Every OPC 40001-1 conformance unit is worded *"using the
DefaultInstanceBrowseName"*, so the namespace a block's browse name sits in is
part of the contract, and it is not always the Machinery one:
`MachineryOperationCounterType` declares `2:OperationCounters` — the Device
Integration namespace — because OPC 40001-1 reuses the OPC 10000-100 functional
group so a client written against Device Integration finds it.
`MachineryItemIdentificationType` and its two subtypes do the same with
`2:Identification`. `MachineryNodeSetConformanceTests` pins all ten values
against the vendored NodeSet.
### OPC 40001-2 process values
```csharp
.WithProcessValue(new QualifiedName("OilTemperature"), pv => pv
.WithEngineeringUnits(degreeCelsius, new Range { Low = -20, High = 120 })
.WithLimits(lowLow: 5, low: 15, high: 65, highHigh: 80)
.WithSetpoint(45)
.WithDeviationLimits(low: -10, high: 10)
.WithPercentageValue()
.WithStatus()
.WithLimitAlarm()
.WithDeviationAlarm()
.WithZeroPointAdjustment(async (processValue, ct) => await ZeroAsync(ct))
.WithValue(45)
.Bind(out IProcessValueHandle oilTemperature))
```
The application drives the value through the handle afterwards, and everything
derived from it follows:
```csharp
await oilTemperature.SetValueAsync(70.0); // percentage, both alarms
await oilTemperature.SetSetpointAsync(55); // deviation alarm
```
For acquired data, `SetValueAsync(in DataValue, cancellationToken)` preserves
the original numeric scalar type, quality and acquisition timestamp. An
unavailable sample can carry `Variant.Null` with a bad status; it is not
converted into a fresh zero or used to clear the last alarm. Incompatible
payloads and canceled updates leave the previous sample unchanged. The
percentage value carries the same quality and source time. Convenience
double-valued updates use an injected `TimeProvider`, or `TimeProvider.System`
when no provider is registered.
`MachineryProcessValue.Sample` returns the full signal `DataValue` on the client,
including uncertain or bad quality and both service timestamps. It complements
the numeric `Value` projection rather than silently normalizing a
failed or unavailable reading.
`machine.WithProcessValue(existingProcessValue, configure)` adopts a
companion-derived descendant without replacing either its object or signal.
The signal must already use the standard `ProcessValueVariableType` subtype
and keep its PADIM-qualified `AnalogSignal` browse name. A companion builder
selects that permitted subtype when constructing the instance; an incompatible
existing signal is rejected rather than replaced. Nested descendants are
supported and remain under their declared parent.
`WithLimitAlarm()` and `WithDeviationAlarm()` do more than create the objects
the model declares. Each one mirrors the limits onto the alarm — absolute
limits from `WithLimits`, deviations from `WithDeviationLimits` — points the
condition at the signal as its `InputNode`, gives it the source, name and
enable state OPC 10000-9 needs, registers it as a root notifier, and
re-evaluates it on every `SetValueAsync`. An alarm that is only created never
activates and never reports.
`WithZeroPointAdjustment` binds the optional OPC 30081 method and reports a
`ZeroPointAdjustmentEventType` event on **every** call — the part's conformance
unit says all instances supporting the method do — with the handler deciding
only the `ZeroPointAdjustmentResult` status the event carries. Because that
event type is abstract, the event is reported with a concrete subtype; see
[Abstract event types](#abstract-event-types).
`ProcessValueType` derives from PADIM's `AnalogSignalType`, and its mandatory
`AnalogSignal` child keeps PADIM's `AnalogSignalVariableType` as its declared
type — OPC 40001-2 does **not** narrow it. What the part adds is
`ProcessValueVariableType`, a *subtype* of that variable type carrying
`LowLowLimit` / `LowLimit` / `HighLimit` / `HighHighLimit` and
`PercentageValue`. An instance may use a subtype in a declared slot, so the
builder puts the richer type there and the limits become reachable. The
generated factory alone would leave the PADIM type in place and
`WithLimits(...)` would have nowhere to write.
The generated subtype preserves inherited reference types and browse-name
namespaces, including when `ProcessValueVariableType` is instantiated directly.
`SignalTag` is a mandatory PA-DIM property, and `EngineeringUnits` is a
namespace-0 property used by Data Access clients and the
`0:Data Access AnalogUnitType` unit of the OPC 40001-2 base facet.
These attributes travel through the cross-assembly dependency payload; see
[Cross-assembly generator behavior](#cross-assembly-generator-behavior).
### OPC 40001-3 job management
```csharp
.WithJobManagement(jobs => jobs
.WithJobOrderReceiver(provider)
.WithJobResponseProvider(provider)
.WithJobOrderCatalog(provider))
```
`JobManagementType` has exactly two mandatory children: `JobOrderControl`, an
ISA-95 `JobOrderReceiverObjectType`, and `JobOrderResults`, an ISA-95
`JobResponseProviderObjectType`. The eleven job verbs — `Store`,
`StoreAndStart`, `Start`, `Stop`, `Abort`, `Pause`, `Resume`, `Clear`,
`Cancel`, `Update`, `RevokeStart` — belong to the ISA-95 type, not to
OPC 40001-3, and are bound by the shared
[`Isa95JobControlV2Binder`](../src/Opc.Ua.ISA95.Server/Providers/Isa95JobControlV2Binder.cs)
that `Isa95NodeManager` uses. A client therefore sees the same verbs with the
same semantics whether it reaches them through a stand-alone ISA-95 root or
through a machine.
When no provider is supplied explicitly the builder resolves
`IIsa95JobOrderReceiverV2` from the application services, so
`AddInMemoryIsa95JobControlProvider()` is enough for a demo server.
Companion models can bind an already-declared job-management child with
`machine.WithJobManagement(existingJobManagement, configure)`. This overload
preserves the child's identity and validates **both** the receiver and response
provider before publishing the machine. Explicit per-machine providers never
borrow another instance's catalog from the container. The
`WithJobManagement(configure)` overload uses receiver-only provider validation;
choose the companion overload when complete response handling is
required.
`MachineryEndToEndTests.JobControlPausesResumesAndRevokesStartThroughAMachineAsync`
drives `Pause`, `Resume` and `RevokeStart` through a machine over `opc.tcp`,
both the accepted transitions and the refused ones (revoking a running order,
pausing one that is not running, resuming one that is), and checks the state
the published `JobOrderList` reports after each.
`JobOrderList` and `JobOrderResponseList` are snapshots the builder keeps
current from the provider's change streams, because a machine changes its
orders by routes that never pass an OPC UA method: `IIsa95JobStatusSourceV2`
and `IIsa95JobOrderCatalogChangeSource` refresh both lists, and
`IIsa95JobResponseChangeSource` refreshes the response list when the machine
reports a response while the order keeps running — progress such as
`RunsCompleted`, which changes neither the order's state nor the catalog.
`InMemoryIsa95JobControlProvider` implements all three.
### OPC 40001-4 energy
```csharp
.WithEnergy(energy => energy
.AddResource(MachineryEnergyCarrier.CompressedAir, air =>
{
air.Main
.WithApplicationTag("press/CompressedAir")
.WithNonElectricalEnergy(importHighPrecision: 0, exportHighPrecision: 0)
.WithVolumeFlow(volume: 184_320f, volumeFlowRate: 18.4f)
.WithBaseFlow(pressure: 620_000f, temperature: 294.65f);
air.AddMeteringPoint(
new QualifiedName("ClampingCylinders"),
point => point.WithNonElectricalEnergy(0, 0));
}))
```
OPC 40001-4 is an extension of the OPC 40001-1 `Monitoring` building block
rather than a block of its own (§6.1), and its structure is three levels deep:
```mermaid
flowchart TD
monitoring["Monitoring"] --> consumption["Consumption<br/>OPC 40001-1 MonitoringType"]
consumption --> air["CompressedAir<br/>Well-known FolderType browse name, section 9.1"]
air --> main["Main<br/>Well-known EnergyMeasurementType name, section 9.2"]
air --> cylinders["ClampingCylinders<br/>Further metering points"]
```
A *metering point* is an OPC 34100 `EnergyMeasurementType` object that
*implements* `INonElectricalEnergyType` and the flow interfaces; the interfaces
are abstract, so nothing is ever an instance of them. `WithEnergy` materialises
the monitoring add-in and the `Consumption` folder when the machine does not
already publish them, and `AddResource` always creates `Main` — the
`Machinery Energy Main grouping` unit requires one for *each* resource folder,
so a resource without it would make the server non-conformant for every other
resource too.
`Contains` (§8.1) runs **from** the main metering point **to** a sub-meter whose
readings are part of it. Both ends must be `EnergyMeasurementType` objects,
which is why it never points at a resource folder or at the same-named Object
the Energy model carries — those Objects exist only to publish the well-known
browse names.
Electricity is the one resource OPC 40001-4 describes with no interfaces of its
own: §6.5 defers it to OPC 34100's energy profiles. `WithInterface` and
`AddMeasurementValue` are how a metering point takes one:
```csharp
.AddResource(MachineryEnergyCarrier.Electricity, electricity =>
electricity.Main
.WithApplicationTag("press/Electricity")
.WithInterface(NodeId.Create(
Opc.Ua.ECM.ObjectTypes.IEnergyProfileE1Type,
Opc.Ua.ECM.Namespaces.ECM,
context.Context.NamespaceUris))
.AddMeasurementValue(
new QualifiedName(Opc.Ua.ECM.BrowseNames.AcActivePowerTotal),
Variant.From(41_250.0f),
watt))
```
The units OPC 40001-4 pins are the UNECE Recommendation 20 codes from its
attribute tables and are applied by the builder: watt hours for the two energy
readings, pascal for `Pressure`, kelvin for `Temperature`.
### OPC 40001-101 results
```csharp
.WithResultManagement(results => results
.WithInMemoryStore(capacity: 32)
.WithResultsFolder(publishedResults: 4)
.WithFileTransfer())
```
All five methods of `ResultManagementType` are optional; the builder publishes
`GetLatestResult`, `GetResultById`, `GetResultIdListFiltered`,
`AcknowledgeResults` and `ReleaseResultHandle` once a store is bound, and the
`ResultTransfer` object only when downloads are enabled.
For a companion-declared subtype, use
`machine.WithResultManagement(existingResultManagement, configure)` instead of
creating a second base add-in. The existing descendant keeps its parent, browse
name, NodeId, type definition and reference type, while the shared runtime
supplies queries, metadata handles, publication and file-transfer lifetime.
Foreign descendants and duplicate bindings are rejected. Stores are validated
before the machine is published, and method nodes are staged before indexing.
`results.WithResultsFolder(count, createResult)` accepts a typed result-variable
factory, and `results.WithResultReadyEvents(eventTypeId, createEvent)` selects a
derived result-ready event factory. The event factory receives the result being
published; any specialized result child it creates is retained. Abstract event
declarations stay unchanged: the runtime registers a concrete subtype in the
application namespace. These overloads are extensions in
`Opc.Ua.Machinery.Server.Builders`, so they require no additional members on
builder-interface implementations.
The `WithResultReadyEvents(eventTypeIds, createEvent)` overload accepts one to
sixteen distinct declared result-event bases. A factory can select a different
configured base for each result, such as a companion's intermediate-result
event instead of its final-result event. Every selected base gets a concrete
application subtype; unconfigured or unrelated types are rejected.
`WithResultProjection(prepareProjection)` captures validated immutable
companion fields and sample metadata once per publication, before storage.
It returns a synchronous assignment-only projection over the supplied result
variable. The shared writer applies that projection after setting the standard
result fields and before issuing change notifications, including the result
inside events. A fixed ring retains the projection with its result when slots
shift, so acquisition time, quality and derived fields do not depend on the
backing store still retaining an old entry. Projection assignments must not
perform I/O or recursively publish results.
#### Bringing your own store
Substitute a real store by implementing
[`IMachineryResultStore`](../src/Opc.Ua.Machinery.Server/Results/MachineryResult.cs)
and passing it to `WithStore(...)`, or by registering it in the container. The
machine's result publisher (`IMachineHandle.Results`, and `Publisher` on the
stand-alone `MachineryResultNodeManager`) works with any store:
- a store that also implements `IWritableMachineryResultStore` receives every
published result through `AddResultAsync` — `InMemoryMachineryResultStore`
is one;
- a store that fills itself through a path of its own — a database a PLC
gateway writes to — does not implement it. Publishing then only announces
the result: the result variables and the `ResultReadyEventType` event. The
store has to hold the result already; one it cannot return from
`GetResultByIdAsync` is refused with `Bad_InvalidState`, because an event
must not announce a result the methods cannot hand out.
With `WithPredefinedResultMetaData()` the metadata check runs before the store
sees the result, whichever kind of store it is.
#### Filtering and ordering the result list
The arguments to `GetResultIdListFiltered` control filtering, ordering and the
maximum number of returned results, as defined by OPC 40001-101 §7.1.3:
- **`filter`** is a `ContentFilter` whose BrowsePaths "can be built from the
fields of the `ResultReadyEventType`, the `ResultType` VariableType or the
`ResultDataType`". All three spellings resolve to the same field — an
optional leading `Result`, then `ResultMetaData` (with its fields, and
`ProcessingTimes` one level further down) or `ResultContent` — and names are
matched without their namespace index, because a DataType field has none.
The comparison, logical and `Like`/`Between`/`InList`/`OfType`/bitwise
operators are supported; `InView` and `RelatedTo` need a node and are FALSE.
- **`orderedBy`** sorts by each RelativePath in turn, ascending. Results that
leave a key unset sort last, and results that tie keep the store's
newest-first order. An empty `orderedBy` "executes no ordering", so the
store's own order stands.
- **`maxResults`** caps the list after filtering and ordering; zero means no
cap.
Most metadata fields are optional, so NULL handling decides what a filter
returns. The evaluator follows OPC 10000-4 §7.7.3: a comparison with an unset
field is NULL and never selects a result — neither does its negation — and only
`IsNull` sees the missing value. An optional field counts as set when its
encoding-mask bit is set or when it carries a non-default value; deciding that
needs the structure, which is why the result query carries
[its own evaluator](../src/Opc.Ua.Machinery.Server/Results/MachineryResultFilterEvaluator.cs)
next to the stack's node-oriented `FilterEvaluator`. Both apply the same NULL
rules.
An invalid filter is refused with `Bad_ContentFilterInvalid`, an `orderedBy`
path that names no field with `Bad_InvalidArgument`.
The query reads the candidates through `GetResultIdsAsync(0)` and
`GetResultByIdAsync`, so filtering and ordering require no additional custom-store
methods. The `timeout` argument of the three getters is the client's hint for
how long it will need the data (§6.4); it is not a deadline for the call.
#### Downloads, and the refused write side
`ResultTransferType` derives from `TemporaryFileTransferType`, so the download
is the standard OPC 10000-5 sequence: `GenerateFileForRead` returns a transient
`FileType` object and a handle that is **already open**, the client reads from
it and closes it.
[`MachineryResultTransferManager`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultTransferManager.cs)
serves it in the shape `SoftwareUpdateFileTransferManager` uses for DI
software uploads: one transient `FileState` per handle, the handle bound to
the session that opened it, a cap on concurrent handles, and a timeout after
which an inactive handle is reclaimed. Pending generations also consume the
cap. Inactivity for a still-live session is swept at the next
`GenerateFileForRead`; session termination and shutdown are independently
awaited. A successful `CloseSession`, including `deleteSubscriptions=false`,
waits for that owner's pending generation or rollback, transient-node deletion
and capacity release. Other sessions retain their own handles. Providers and
registration callbacks must honor cancellation or complete; cleanup does not
abandon an unbounded late operation.
`GenerateFileForWrite` and `CloseAndCommit` are mandatory on every
`TemporaryFileTransferType` (OPC 10000-20 §4.4.1), so a `ResultTransfer`
object always carries them, and they answer. OPC 40001-101 §7.2.1 leaves
`GenerateFileForWrite` unspecialised "as results are supposed to be only
generated by the Server, not received", which is the read-only case
OPC 10000-20 names a code for: `GenerateFileForWrite` returns
`Bad_NotWritable` (§4.4.4), and `CloseAndCommit` returns
`Bad_InvalidArgument` (§4.4.5) for any handle — a download handle included,
which stays open and is closed with the file's own `Close`.
`WithResultsFolder` publishes the most recent results as `ResultType` variables
as well, as required by the `Machinery-Result ResultVariables` unit —
an empty folder satisfies nothing. The variables are a fixed ring created with
the machine: the newest result lands in the first slot and the others shift
down, so a client can subscribe to a slot once and keep receiving results, and
the server never adds or deletes nodes while it runs.
Before a slot receives its first result, its value and structured parts are
null. Model-default XML is not a published result and is not exposed
as one. A present Good-null slot has no ResultId to inspect or download; clients
can keep monitoring the same slot until the publisher fills it.
`GetLatestResult`, `GetResultById` and `GetResultIdListFiltered` each return a
`ResultHandle` that pins what they handed out until the client calls
`ReleaseResultHandle`. The handle is bound to the calling session, and
releasing one twice is refused. Closing or expiring the session reclaims only
its metadata handles. A late store response cannot pin additional metadata for an
ended session, and removal of the bound machine releases the lifecycle hook.
Because the payload is materialised from the store before the method returns,
`GenerateFileForRead` returns `NodeId.Null` for `completionStateMachine` — the
value OPC 10000-5 defines for "no completion state machine".
## Abstract event types
Two of the event types the series declares are **abstract**:
OPC 40001-101's `ResultReadyEventType` and OPC 40001-2's
`ZeroPointAdjustmentEventType`. OPC 10000-3 forbids an instance of an abstract
type, and a client filtering on a concrete `EventType` never sees an event
reported with an abstract one — so a server that means to publish them has to
derive a concrete subtype of its own.
[`MachineryConcreteEventTypes`](../src/Opc.Ua.Machinery.Server/MachineryConcreteEventTypes.cs)
mints one per node manager into the application-owned instance namespace and
every object reporting that event shares it; deriving one per instance would
multiply types a client has to know about for no gain. Both the
`TypeDefinitionId` and the `EventType` field are set, because the second is
what an event filter actually selects on.
`Isa95NodeManager.CreateV2StatusEventTypeAsync` does the same for the ISA-95
job-order status event.
The client side mirrors it. An `OfType` filter on the abstract type still
selects the subtype — that is what `OfType` means — but the generated decoder
registry is keyed by the exact event type, so
[`MachineryClient.Events`](../src/Opc.Ua.Machinery.Client/MachineryClient.Events.cs)
re-decodes against the declared abstract type once the session's type cache
confirms the subtype relationship.
## State machines are server-driven
Both OPC 40001-1 state machines — `MachineryItemState_StateMachineType` with
`Executing` / `NotExecuting` / `NotAvailable` / `OutOfService`, and
`MachineryOperationModeStateMachineType` with `None` / `Maintenance` / `Setup` /
`Processing` — declare four states and sixteen transitions each, including the
self-transitions. **Neither declares a cause method.** The whole Machinery
NodeSet contains no `UAMethod` at all, so a client can never request a
transition.
The server surface follows: `IMachineryItemStateController.SetStateAsync` and
`IMachineryOperationModeController.SetModeAsync`, reachable from the machine
handle. There is deliberately no `WithCause`.
```csharp
await press.ItemState!.SetStateAsync(MachineryItemStateValue.Executing);
await press.OperationMode!.SetModeAsync(MachineryOperationModeValue.Processing);
```
The state and transition tables live in
[`MachineryStateMachineTables`](../src/Opc.Ua.Machinery.Server/StateMachines/MachineryStateMachineTables.cs),
spelled out from the generated `*TypeIds` constants — the generator emits these
two types as plain `FiniteStateMachineState` subclasses without the stack's
`StateTable` / `TransitionTable` / `TransitionMappings` overrides, so the
stack's own `SetState` would write nothing. The driver still invokes the
machine's `OnBeforeTransition` / `OnAfterTransition` delegates around the
variable update, which is exactly where `StateMachineBuilder.For(...)` installs
its guards and observers, so the repository's state-machine lifecycle surface
keeps working.
## Client
`MachineryClient` starts at the `Machines` folder, so it needs no configured
NodeIds and works against a server it has never seen.
| Concern | API |
| --- | --- |
| Discovery | `EnumerateMachinesAsync`, `DiscoverMachinesAsync`, `EnumerateComponentsAsync`, `EnumerateBuildingBlocksAsync` |
| Identification | `ReadIdentificationAsync` — one `TranslateBrowsePaths` plus one batched `Read` |
| State | `ItemStateAsync` / `OperationModeAsync` return the generated FSM proxies; `GetItemStateAsync` / `ObserveItemStateAsync` wrap them |
| Health | `ReadDeviceHealthAsync` |
| Counters | `ReadOperationCountersAsync`, `ReadLifetimeCountersAsync` |
| Equipment | `EnumerateEquipmentAsync` — including `EquipmentLife` |
| Process values | `EnumerateProcessValuesAsync`, `ReadProcessValueAsync`, `ZeroPointAdjustmentAsync`, `ObserveZeroPointAdjustmentsAsync` |
| Energy | `ResolveConsumptionAsync`, `EnumerateEnergyResourcesAsync`, `EnumerateMeteringPointsAsync`, `ReadMainMeteringPointAsync`, `ReadMeteringPointAsync`, and for the sub-meters a point `Contains` `EnumerateSubMetersAsync` / `ReadSubMetersAsync` |
| Jobs | `JobManagementAsync` returns the ISA-95 `Isa95JobControlV2Client`; `ReadJobOrdersAsync` / `ReadJobResponsesAsync` read the published lists; `ReadJobOrderParametersAsync` / `ReadJobResponseParametersAsync` return the predefined OPC 40001-3 parameters typed |
| Results | `ResultManagementAsync` (whose `GetResultIdListFilteredAsync` takes the filter and order), `DownloadResultAsync`, `ReadPublishedResultsAsync`, `ObserveResultsAsync` |
| Notifications | `ResolveNotificationsAsync`, `ObserveNotificationsAsync` |
| Device Integration | `Topology` — for a machine that is also a DI device |
Process values are found by type definition rather than by browse path:
OPC 40001-2 does not fix where they hang — its examples put them on a sensor
component, on the machine, and below `Monitoring` — so every object below the
item whose type is `ProcessValueType` counts.
A metering point's measurements are read with one browse and one batched
`Read`. Each `MachineryMeasurementValue` carries the `NodeId` of the variable
its reading came from next to the value, so a client that wants live values
subscribes to those nodes without browsing the point again:
```csharp
MachineryMeteringPoint? main = await machinery.ReadMainMeteringPointAsync(resource, ct);
foreach (MachineryMeasurementValue measurement in main!.Measurements)
{
subscription.TryAddMonitoredItem(
measurement.BrowseName.Name!,
measurement.NodeId,
options => options with { SamplingInterval = TimeSpan.FromSeconds(1) },
out _);
}
```
State observation rides the generated `*StateMachineTypeClient` proxies, which
inherit `GetCurrentFiniteStateAsync`, `ObserveFiniteTransitionsAsync` and
`WaitForStateAsync` from `FiniteStateMachineTypeClient` — so a vendor subtype of
either state machine is observed the same way. See
[StateMachines.md](StateMachines.md).
Constructing the client registers the OPC 40001-101 and OPC 40001-3
structured types, and the ISA-95 Job Control V2 ones the job lists travel as,
with the session's encodeable factory, so `GetLatestResult` decodes into
`ResultDataType` and `JobOrderList` into `ISA95JobOrderAndStateDataType`
instead of an opaque `ExtensionObject`.
The predefined job parameters of OPC 40001-3 §7.2 Table 1 travel as ISA-95
key-value pairs rather than as nodes, so the client reads them out of the
payload: `MachineryJobOrderParameters.FromJobOrder` and
`MachineryJobResponseParameters.FromJobResponse` return each one with the
type the table declares — `Duration` in milliseconds, the series' own
enumerations as `JobExecutionMode` / `JobResult`, and `Overproduction` from
where Table 1 puts it, below `PlannedOrderQuantity` — and keep every
parameter as received in `Parameters`. A parameter with the wrong type counts
as not carried in these convenience projections. Optional fields of the
generated ISA-95 structures, such as `JobOrderParameters` and `Subparameters`,
only reach the wire when their `EncodingMask` bit is set.
### Bounded, evidenced inspection
The two-argument constructor and `session.Machinery(telemetry)` use tolerant
reads. For inspection that must not confuse an unreadable value with an absent
building block, opt into strict reads:
```csharp
MachineryClient machinery = session.Machinery(telemetry, new MachineryClientOptions
{
MaximumBrowseReferences = 1024,
MaximumBrowsePages = 128,
MaximumReadArrayItems = 1024
});
// Discovery reads identities, not every machine's details.
await foreach (MachineEntry machine in machinery.EnumerateMachinesAsync(ct))
{
Console.WriteLine($"{machine.NodeId}: {machine.DisplayName}");
}
// Read details only for a selected machine.
MachineIdentification? identification =
await machinery.ReadIdentificationAsync(selectedMachineId, ct);
if (identification is not null)
{
foreach (MachineryReadValue field in identification.Values)
{
Console.WriteLine(field.IsAbsent
? $"{field.BrowsePath}: not published"
: $"{field.SourceNodeId}: {field.Value.WrappedValue}, " +
$"{field.Value.StatusCode}, source={field.Value.SourceTimestamp}, " +
$"server={field.Value.ServerTimestamp}");
}
}
MachineryStateSnapshot? state = await machinery.ReadItemStateAsync(selectedMachineId, ct);
MachineryReadValue health = await machinery.ReadDeviceHealthValueAsync(selectedMachineId, ct);
```
The same immutable options can be passed to
`new MachineryClient(session, telemetry, options)`,
`new MachineryClientFactory(sessionFactory, telemetry, options)` or
`AddMachineryClient(options)` on the client builder. Each construction
path validates positive limits and copies the options. The session is borrowed,
never closed or disposed.
Strict reads require Good service, operation and value statuses, exact response
and diagnostic counts, and one complete **local** translation target.
Ambiguous, remote, incomplete or unknown-namespace targets fail without changing
the namespace table. Only an optional path's `BadNoMatch` denotes absence;
missing mandatory fields, denied reads, Uncertain quality, wrong wire types and
malformed responses are errors, not null/default values. A published Good-null
value is present (`IsAbsent == false`). Scalar UInt32 fields reject a
StatusCode payload even though `Variant.TryGetValue(out uint)` can accept it.
`MachineryReadException` derives from `ServiceResultException`: its `StatusCode`
preserves the actual non-Good status, or describes a type/shape/limit failure.
`ReadValue` identifies the offending source and qualified path and retains the
original DataValue when one was returned, including both timestamps and
picoseconds. Translation failures and requests without a returned value instead
carry synthetic status evidence. The failure's status does not overwrite an
original Good DataValue when the payload has the wrong type.
| Inspection | Evidence API |
| --- | --- |
| Identification, operation/lifetime counters, equipment, process values, meters and measurements | Records expose `ArrayOf<MachineryReadValue> Values`; tolerant reads leave this empty |
| Item state and operation mode | `ReadItemStateAsync` / `ReadOperationModeAsync` return `MachineryStateSnapshot` with the compatible `State`, `Values`, optional `CurrentStateNumber`, `LastTransitionNumber` and `TransitionTime` |
| Health | `ReadDeviceHealthValueAsync` returns one `MachineryReadValue`; `ReadDeviceHealthAsync` retains its enum projection |
| Published job lists | `ReadJobOrdersSnapshotAsync` / `ReadJobResponsesSnapshotAsync` return typed arrays plus original list evidence |
| Published result identities | `EnumeratePublishedResultsAsync` does not read result content |
| Selected published result | `ReadPublishedResultAsync(resultVariableId, ct)` returns `MachineryResultSnapshot` without downloading a file or requesting a result URI |
| Explicit multi-result inspection | `ReadPublishedResultSnapshotsAsync` reads one variable per consumed iteration; stopping enumeration prevents further value reads |
Evidence-specific APIs use strict reads, with default limits when the client
is constructed without options. Enum, array and `FiniteStateSnapshot` APIs
provide convenience projections; use the evidence APIs to distinguish absent,
empty and Good-null values.
Each result owns its evidence: overlapping calls do not share mutable
last-read state.
Browse limits are checked **during** Browse/BrowseNext traversal, before
accumulating a page or deduplicating identities. Duplicate references and empty
pages count. Process-value discovery shares its budget across the machine and
Monitoring roots. Continuation points are opaque and may remain unchanged while
pages advance; the page and reference budgets still bound the traversal.
Exceeding a budget fails with `BadEncodingLimitsExceeded`, never silently
truncates a result. Outstanding points are released using an independent
five-second deadline that also bounds an unresponsive release call. Standard
empty release responses and validated per-point responses are accepted.
If traversal and cleanup both fail,
`AggregateException` preserves the primary failure first.
Bounded browsing and selected-result downloads capture one authenticated
`ManagedSession` generation for their service calls and cleanup. Result-path
resolution is part of that same download scope. Recovery invalidates the scope
instead of forwarding an old continuation point, resolved target or handle to
a replacement session. Ordinary borrowed sessions are checked for a changed
SessionId; callers must not independently retarget their transport or identity
while an operation is running.
Strict lifetime counters and a present `EquipmentLife` require `StartValue`
and `LimitValue`. Their scalar `Number` fields accept floating-point and integer
values; `WarningValues` accepts either a scalar Number or a one-dimensional
numeric array. The `ArrayOf<double>` warning projection is populated
only for Double arrays; other valid forms remain unmodified in `Values`.
Operation durations retain their Double-only wire contract.
Strict meters require `ApplicationTag`, while a published Good-null tag remains
present. Measurements are checked against their published `DataType` and
`ValueRank`, including the ECM `AcPeDataType` and `AcPpDataType` structures.
Properties such as IA `StartTime` remain in `Values` rather than being reported
as numeric measurements; StartTime must carry a DateTime. Clients constructed
without options use tolerant projections.
`MaximumReadArrayItems` bounds decoded arrays and the aggregate modeled array
items in each job-list or result snapshot, including nested parameter arrays.
OPC 40001-2 permits Number arrays for AnalogSignal and Setpoint; strict reads
retain those bounded arrays in `Values`, with a null scalar convenience
projection. Scalar numeric projections can lose integer precision; the original
Variant remains authoritative. These limits **do not bound transport decoding
or opaque vendor ExtensionObject bodies**. Configure the session's message,
array, string and ByteString quotas as well.
Missing optional model namespaces and building blocks remain normal absence.
Where a value-level evidence API reports a missing namespace,
`MissingNamespaceUri` names it without inventing an index. A published model
missing a required dependency is a failure.
In strict mode, `ReadJobResponseParametersAsync` inspects only the published
response list without invoking response-request methods. An
absent catalog or unlisted order therefore returns null; multiple matching
responses fail explicitly. Static `FromJobOrder` / `FromJobResponse` projections
use tolerant extraction. Generated proxies, the `Topology` client, event
subscriptions, job commands and result-file transfers have their own contracts;
these options do not harden those separate surfaces.
`EnumerateEquipmentAsync` is a detailed equipment reader, not an
identity-only browse.
### Job endpoints and streamed results
`GetJobManagementEndpointsAsync` returns the machine's actual job roles:
```csharp
MachineryJobManagementEndpoints endpoints =
await client.GetJobManagementEndpointsAsync(machineId, ct);
if (!endpoints.JobResponseProviderId.IsNull)
{
var responses = new Opc.Ua.ISA95.JobControl.V2.ISA95JobResponseProviderObjectTypeClient(
client.Session, endpoints.JobResponseProviderId, client.Telemetry);
var response = await responses.RequestJobResponseByJobOrderIDAsync("job-1", ct);
}
```
Missing roles are `NodeId.Null`; Machinery defines no response receiver.
The three-role `JobManagementAsync` helper returns null when the order receiver
is absent. Otherwise it uses that receiver for the undefined response-receiver
role and as a fallback for a missing response provider. Use the actual
role-specific endpoint when that distinction matters. ISA-95 return codes are
UInt64 bitmaps; only `1` means success, independently of the UA service status.
`DownloadResultAsync(machine, resultId, destination, ct)` streams through
`TemporaryFileTransferClient` and leaves the destination open. The ByteString
overload shares that implementation. Advanced callers can use
`OpenResultStreamAsync` and must asynchronously dispose its `UaFileStream`.
## Conformance matrix
Status key: ✅ implemented and tested · 📄 static NodeSet structure only ·
🔲 optional per the published modelling rule · ❌ not shipped.
Conformance units are advertised at runtime, not statically. The series has two
kinds of unit and the manager answers them differently:
- a **type-exposure** unit — "the server exposes this type and all its
supertypes" — is satisfied by loading the model, so it follows
`MachineryParts`;
- every other unit talks about instances, methods or references, and is
reported only once a build actually materialised **and** wired the structure
behind it.
A server that builds no machine therefore advertises nothing but
`Machinery Find Machines` and whatever types its parts loaded.
### OPC 40001-1 building blocks
| Area | Static | Runtime | Source | Tests |
|---|---|---|---|---|
| `Machines` folder organized from `Objects` (`Machinery Find Machines`) | ✅ | ✅ | [`MachineryNodeManager`](../src/Opc.Ua.Machinery.Server/MachineryNodeManager.cs) | `MachineryModelLoadingTests` |
| `MachineryBuildingBlocks` organizer (`Machinery Building Block Organization`) | ✅ | ✅ | [`MachineryItemBlocks`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBlockBuilders.cs) | `MachineryBuildingBlockTests` |
| Machine identification (`Machinery Machine Identification`) | ✅ | ✅ | [`MachineryBlockBuilders`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBlockBuilders.cs) | `MachineBuilderTests` |
| Component identification and discovery (`Machinery Component Identification`, `Machinery Find Components of Machines`) | ✅ | ✅ | same | `MachineBuilderTests` |
| Monitoring add-in (`Machinery Monitoring`) | ✅ | ✅ | same | `MachineBuilderTests` |
| `Monitoring/Health` with DI `DeviceHealth` and `DeviceHealthAlarms` | ✅ | ✅ | [`MachineryHealthBuilder`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBlockBuilders.cs) | `MachineBuilderTests`; `MachineryEndToEndTests` |
| Item state machine, four states / sixteen transitions, no causes (`Machinery MachineryItem State`) | ✅ | ✅ | [`MachineryStateMachineTables`](../src/Opc.Ua.Machinery.Server/StateMachines/MachineryStateMachineTables.cs) | `MachineBuilderTests`; `MachineryStateMachineTests` |
| Operation-mode state machine (`Machinery Operation Mode`) | ✅ | ✅ | same | same |
| Operation counters (`Machinery Operation Counter`) | ✅ | ✅ | [`MachineryBlockBuilders`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBlockBuilders.cs) | `MachineBuilderTests`; `MachineryEndToEndTests` |
| Lifetime counters (`Machinery Lifetime Counter`) | ✅ | ✅ | same | `MachineBuilderTests` |
| Machinery equipment (`Machinery MachineryEquipment`) incl. `EquipmentLife` | ✅ | ✅ | same | `MachineBuilderTests`; `MachineryEndToEndTests` |
| Notifications (`Machinery Notifications`) with a publish seam | ✅ | ✅ | [`MachineryNotificationsBuilder`](../src/Opc.Ua.Machinery.Server/Builders/MachineryNotificationsBuilder.cs) | `MachineBuilderTests` |
| Stacklight over OPC 10000-200 IA | ✅ | 🔲 | [`MonitoringBuilder`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBlockBuilders.cs) | `MachineBuilderTests` |
| Writable identification (`Machinery Machine Identification Writable`, `Component Identification Mandatory`, `Component Identification Writable`) | ✅ | ✅ opt-in via `MachineryIdentificationData.Writable` | [`MachineryBlockBuilders`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBlockBuilders.cs) | `MachineryPartsBuilderTests` |
### OPC 40001-2 process values
| Area | Static | Runtime | Source | Tests |
|---|---|---|---|---|
| `ProcessValueType` instances over the `ProcessValueVariableType` subtype (`… Base Types`, `… Analog Object Instances`) | ✅ | ✅ | [`ProcessValueBuilder`](../src/Opc.Ua.Machinery.Server/Builders/ProcessValueBuilder.cs) | `MachineryProcessValueTests`; `MachineryEndToEndTests` |
| Setpoint (`… Base SetpointType`, `… Base Process Value Setpoint`) | ✅ | ✅ | same | `MachineryProcessValueTests` |
| Limits on the signal (`… Limits Base`) | ✅ | ✅ | same | `MachineryProcessValueTests` |
| Exclusive limit alarm driven by the value (`… Limits Alarm`, `… Limits Alarm Object`) | ✅ | ✅ | [`MachineryProcessValueAlarm`](../src/Opc.Ua.Machinery.Server/Builders/MachineryProcessValueAlarm.cs) | `MachineryProcessValueTests` |
| Deviations on the setpoint (`… Deviation Base`, `… Deviation AutoAdjustment`, `… Deviation Sensitivity`) | ✅ | ✅ | [`ProcessValueBuilder`](../src/Opc.Ua.Machinery.Server/Builders/ProcessValueBuilder.cs) | `MachineryProcessValueTests` |
| Exclusive deviation alarm against the setpoint (`… Deviation Alarm`, `… Deviation Alarm Object`) | ✅ | ✅ | [`MachineryProcessValueAlarm`](../src/Opc.Ua.Machinery.Server/Builders/MachineryProcessValueAlarm.cs) | `MachineryProcessValueTests` |
| `PercentageValue` kept in step with the range (`… Percentage Value`) | ✅ | ✅ | [`ProcessValueBuilder`](../src/Opc.Ua.Machinery.Server/Builders/ProcessValueBuilder.cs) | `MachineryProcessValueTests` |
| `Status` and `AlarmSuppression` (`… Monitoring`, `… AlarmSuppression`) | ✅ | ✅ | same | `MachineryProcessValueTests` |
| `ZeroPointAdjustment` method and its event (`… Base EventTypes`, `… ZeroPointAdjustment Events`) | ✅ | ✅ | same | `MachineryProcessValueTests`; `MachineryEndToEndTests` |
| Simulation (`PA-DIM AnalogSignalVariable Simulation`) | ✅ | ✅ opt-in via `WithSimulation()` | [`ProcessValueBuilder`](../src/Opc.Ua.Machinery.Server/Builders/ProcessValueBuilder.cs) | `MachineryProcessValueTests` |
| Device object (`… Device Object`, `… Simple Device Info`) | ✅ | ✅ opt-in via `WithProcessValueDevice()` | [`MachineBuilder`](../src/Opc.Ua.Machinery.Server/Builders/MachineBuilder.cs) | `MachineryPartsBuilderTests` |
### OPC 40001-3 job management
| Area | Static | Runtime | Source | Tests |
|---|---|---|---|---|
| `JobManagement` as an AddIn under the organizer, composing ISA-95 Job Control V2 (`Machinery Job Management Base`, `… Minimum String Length`) | ✅ | ✅ | [`JobManagementBuilder`](../src/Opc.Ua.Machinery.Server/Builders/JobManagementBuilder.cs) | `MachineryPartsBuilderTests`; `MachineryEndToEndTests` |
| Job verbs through a machine, including `Pause`, `Resume` and `RevokeStart` and their refusals | ✅ | ✅ | [`Isa95JobControlV2Binder`](../src/Opc.Ua.ISA95.Server/Providers/Isa95JobControlV2Binder.cs) | `MachineryEndToEndTests` |
| `JobOrderList` and `JobOrderResponseList` kept current, including responses reported while an order runs | ✅ | ✅ | [`JobManagementBuilder`](../src/Opc.Ua.Machinery.Server/Builders/JobManagementBuilder.cs) | `MachineryEndToEndTests` |
| Job results (`Machinery Job Management Result Base`) | ✅ | ✅ | same | `MachineryPartsBuilderTests` |
| The predefined `Planned …` / `Result …` job-order parameters (42 units) | ✅ | ✅ opt-in via `WithPredefinedParameters()` — the 37 IDs of §9 are recognised and their declared types enforced; anything the series does not predefine still travels untouched | [`MachineryJobParameters`](../src/Opc.Ua.Machinery.Server/Jobs/MachineryJobParameters.cs) | `MachineryPartsBuilderTests` |
### OPC 40001-4 energy
| Area | Static | Runtime | Source | Tests |
|---|---|---|---|---|
| Resource folders below `Monitoring/Consumption` (`Machinery Energy Base Structure`) | ✅ | ✅ | [`MachineryEnergyBuilder`](../src/Opc.Ua.Machinery.Server/Builders/MachineryEnergyBuilder.cs) | `MachineryPartsBuilderTests`; `MachineryEndToEndTests` |
| A `Main` metering point per resource (`Machinery Energy Main grouping`) | ✅ | ✅ — checked at build time, so a resource folder attached to `Consumption` past the builder fails the build instead of breaking the unit | same; check in [`MachineryBuildScope`](../src/Opc.Ua.Machinery.Server/Builders/MachineryBuildScope.cs) | same; `MachineryPartsBuilderTests` |
| `INonElectricalEnergyType` on a metering point (`… Non Electrical Base`) | ✅ | ✅ | same | same |
| Volume- and mass-flow interfaces (`… Volume Flow`, `… Mass Flow`) | ✅ | ✅ | same | `MachineryPartsBuilderTests` |
| `Contains` from `Main` to a sub-meter (`Machinery Energy Contains`) | ✅ | ✅ | same | same; `MachineryEndToEndTests` |
| Electricity | ✅ | ✅ | same — OPC 40001-4 defers it to OPC 34100's own interfaces, which `WithInterface` and `AddMeasurementValue` attach | `MachineryEndToEndTests` |
### OPC 40001-101 results
| Area | Static | Runtime | Source | Tests |
|---|---|---|---|---|
| Result types (`Machinery-Result Types`) | ✅ | ✅ | [`MachineryResultManagementBinder`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultManagementBinder.cs) | `MachineryPartsBuilderTests` |
| The five optional methods (`… GetLatestResult`, `… GetResultById`, `… GetResultsFiltered`, `Machinery Result AcknowledgeResults`) | ✅ | ✅ | same | `MachineryPartsBuilderTests` |
| `filter`, `orderedBy` and `maxResults` of `GetResultIdListFiltered` (§7.1.3), with OPC 10000-4 NULL semantics | ✅ | ✅ | [`MachineryResultQuery`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultQuery.cs), [`MachineryResultFilterEvaluator`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultFilterEvaluator.cs) | `MachineryResultQueryTests`; `MachineryEndToEndTests` |
| Mandatory `GenerateFileForWrite` / `CloseAndCommit` answering for a read-only result file | ✅ | ✅ | [`MachineryResultTransferManager`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultTransferManager.cs) | `MachineryResultQueryTests`; `MachineryEndToEndTests` |
| Publishing through an application-supplied store, writable or self-ingesting | — | ✅ | [`MachineryResult`](../src/Opc.Ua.Machinery.Server/Results/MachineryResult.cs) | `MachineryResultQueryTests` |
| `GenerateFileForRead` download path with session-bound handles, cap and timeout (`… ResultFiles`) | ✅ | ✅ | [`MachineryResultTransferManager`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultTransferManager.cs) | `MachineryPartsBuilderTests`; `MachineryEndToEndTests` |
| Result variables in the `Results` folder (`… ResultVariables`) | ✅ | ✅ | [`MachineryResultVariables`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultVariables.cs) | `MachineryPartsBuilderTests`; `MachineryEndToEndTests` |
| Predefined result metadata (`Machinery-Result PredefinedResultMetaData`) | ✅ | ✅ opt-in via `WithPredefinedResultMetaData()` — refused on publish and on the way out of a bound store | [`PredefinedResultMetaData`](../src/Opc.Ua.Machinery.Server/Results/PredefinedResultMetaData.cs) | `MachineryPartsBuilderTests` |
| Result-ready events with a concrete event type (`… ResultEvents`) | ✅ | ✅ | [`MachineryResultManagementBinder`](../src/Opc.Ua.Machinery.Server/Results/MachineryResultManagementBinder.cs) | `MachineryPartsBuilderTests`; `MachineryEndToEndTests` |
| Stand-alone result server without DI or the machine model, reporting the same units and facets | ✅ | ✅ | [`MachineryResultNodeManager`](../src/Opc.Ua.Machinery.Server/MachineryResultNodeManager.cs) | `MachineryResultServerTests` |
| Durable result store | ❌ not shipped — the storage technology is an open decision; an application plugs its own in through `IMachineryResultStore` and publishes through it | — | — | — |
### Client and hosting
| Area | Static | Runtime | Source | Tests |
|---|---|---|---|---|
| Discovery, identification, components, building blocks | — | ✅ | [`MachineryClient`](../src/Opc.Ua.Machinery.Client/MachineryClient.cs) | `MachineryClientTests`; `MachineryEndToEndTests` |
| State observation over both state machines | — | ✅ | [`MachineryClient.StateMachines`](../src/Opc.Ua.Machinery.Client/MachineryClient.StateMachines.cs) | same |
| Process values, zero-point adjustment | — | ✅ | [`MachineryClient.ProcessValues`](../src/Opc.Ua.Machinery.Client/MachineryClient.ProcessValues.cs) | `MachineryEndToEndTests` |
| Energy resources, metering points and sub-meters | — | ✅ | [`MachineryClient.Energy`](../src/Opc.Ua.Machinery.Client/MachineryClient.Energy.cs) | same |
| Counters, equipment, device health | — | ✅ | [`MachineryClient.Counters`](../src/Opc.Ua.Machinery.Client/MachineryClient.Counters.cs) | same |
| Job control | — | ✅ | [`MachineryClient.Accessors`](../src/Opc.Ua.Machinery.Client/MachineryClient.Accessors.cs) | same |
| Job lists and predefined job parameters | — | ✅ | [`MachineryClient.Jobs`](../src/Opc.Ua.Machinery.Client/MachineryClient.Jobs.cs), [`MachineryJobParameters`](../src/Opc.Ua.Machinery.Client/MachineryJobParameters.cs) | `MachineryJobParameterClientTests`; `MachineryEndToEndTests` |
| Result download, published result variables, result and zero-point events | — | ✅ | [`MachineryClient.Events`](../src/Opc.Ua.Machinery.Client/MachineryClient.Events.cs) | same |
| Server/client DI wiring (`AddMachinery`, `AddMachineryResults`, `AddMachineryClient`) | — | ✅ | [`OpcUaServerMachineryBuilderExtensions`](../src/Opc.Ua.Machinery.Server/Hosting/OpcUaServerMachineryBuilderExtensions.cs) | `MachineryHostingTests`; `MachineryAotTests` |
| Facet URIs on `ServerProfileArray` | — | ✅ | [`ServerProfiles`](../src/Opc.Ua.Machinery.Server/ConformanceUnit.cs) | `MachineryEndToEndTests` reads them back over the wire |
### Where the facet URIs come from
Each URI in [`ServerProfiles`](../src/Opc.Ua.Machinery.Server/ConformanceUnit.cs)
is the published value from the "Profile URIs" table of the matching part, at
the exact model version this repository vendors. The three URI shapes are the
specifications' own and are reproduced verbatim, because conformance tooling
matches a facet URI literally:
| Part | Shape | Example |
| --- | --- | --- |
| 40001-1, -3, -101 | `…/UA-Profile/Machinery/…`, no trailing slash | `http://opcfoundation.org/UA-Profile/Machinery/Server/State` |
| 40001-2 | `…/UA/Machinery/ProcessValues/…`, **with** a trailing slash | `http://opcfoundation.org/UA/Machinery/ProcessValues/Server/Base/` |
| 40001-4 | `…/UA-Profile/Machinery/Energy/…` | `http://opcfoundation.org/UA-Profile/Machinery/Energy/Server/Base` |
A facet is advertised only when every conformance unit the specification marks
mandatory for it, **and that this library is responsible for**, was
materialised and wired. The base-server units each facet also inherits —
address space, view, attribute, method and event subscription — are the stack's
to report and already appear in `base.ServerProfiles`.
<a id="model-sources-identifier-tables-and-repairs"></a>
## Model sources and identifier tables
Every NodeSet is vendored unmodified from
[OPCFoundation/UA-Nodeset](https://github.com/OPCFoundation/UA-Nodeset) (branch
`latest`). Two files are renamed on the way in, because upstream's names do not
match the assembly they belong to; renaming is safe because
`NodeSetToModelDesign.IsNodeSet` inspects the document, not the file name.
| Repository path | Upstream path |
| --- | --- |
| `src/Opc.Ua.Machinery/Model/Opc.Ua.Machinery.NodeSet2.xml` | `Machinery/Opc.Ua.Machinery.NodeSet2.xml` |
| `src/Opc.Ua.Machinery.ProcessValues/Model/Opc.Ua.Machinery.ProcessValues.NodeSet2.xml` | `Machinery/ProcessValues/Opc.Ua.Machinery.ProcessValues.NodeSet2.xml` |
| `src/Opc.Ua.Machinery.Jobs/Model/Opc.Ua.Machinery.Jobs.NodeSet2.xml` | `Machinery/Jobs/Opc.Ua.Machinery.Jobs.Nodeset2.xml` *(lower-case `s`)* |
| `src/Opc.Ua.Machinery.Energy/Model/Opc.Ua.Machinery.Energy.NodeSet2.xml` | `Machinery/Energy/Opc.Ua.Machinery.Energy.NodeSet2.xml` |
| `src/Opc.Ua.Machinery.Result/Model/Opc.Ua.Machinery.Result.NodeSet2.xml` | `Machinery/Result/Opc.Ua.Machinery_Result.NodeSet2.xml` *(underscore)* |
| `src/Opc.Ua.IA/Model/Opc.Ua.IA.NodeSet2.xml` | `IA/Opc.Ua.IA.NodeSet2.xml` |
| `src/Opc.Ua.ECM/Model/Opc.Ua.ECM.NodeSet2.xml` | `ECM/Opc.Ua.ECM.NodeSet2.xml` |
| `src/Opc.Ua.PADIM/Model/Opc.Ua.PADIM.NodeSet2.xml` | `PADIM/Opc.Ua.PADIM.NodeSet2.xml` |
| `src/Opc.Ua.PADIM/Model/Opc.Ua.IRDI.NodeSet2.xml` | `PADIM/Opc.Ua.IRDI.NodeSet2.xml` |
### What the identifier table is and is not
Each model carries a `Model/*.NodeIds.csv` wired up as
`ModelSourceGeneratorIdentifierFile`. It is **not** a NodeId source — for a
NodeSet2 input the NodeIds come from the XML
(`ModelDesignValidator.LoadDesignFile` routes NodeSet2 documents through
`NodeSetToModelDesign.Import`; only ModelDesign inputs use the CSV fallback).
It is a **drift guard**: the generator validates every row against the imported
symbols and fails the build with `MODELGEN022`…`MODELGEN029` when the vendored
model and the pinned mapping disagree. This catches types dropped from the
generated API.
`Model/*.Upstream.NodeIds.csv` is the OPC Foundation's publication unmodified
and is deliberately kept out of `<AdditionalFiles>`, so the two can be diffed to
compare the published and derived mappings, following the convention used by
`src/Opc.Ua.ISA95`.
### Why the published tables need deriving
The published tables carry the UA-ModelCompiler's symbolic names, which differ
from the ones this repository's generator derives. Three differences occur; all
three are handled by
[`tools/nodesets/derive-identifier-table.py`](../tools/nodesets/derive-identifier-table.py),
which reimplements `NodeSetToModelDesign.ImportSymbolicName` / `BuildSymbolicId`:
| Published | Derived | Why |
| --- | --- | --- |
| `Server_Namespaces_http___…_` | `http___…_` | the publication qualifies the namespace-metadata object with its server path |
| `X_ControlChannel` | `X_ControlChannel_Placeholder` | a `<Placeholder>` BrowseName maps to `Name_Placeholder` |
| `DefaultBinary` | `RGBWDataType_Encoding_DefaultBinary` | encoding nodes are qualified with the data type they encode |
Two publications also deviate in shape rather than in naming, and the script
reads both: **ECM** publishes its table as `Opc.Ua.ECM.NodeSet2.csv` rather than
`*.NodeIds.csv`, and **PADIM** publishes a tab-separated table with an
`ID / Browsename / Node Class` header — identifier first, and BrowseNames rather
than symbolic names. **IRDI** has no published table at all and therefore
carries no drift guard.
The numeric ids and node classes agree with the NodeSets in all nine models.
This comparison does not check references, data types, modelling rules or behavior.
<a id="generator-gaps-found-while-adding-these-models"></a>
## Cross-assembly generator behavior
Models consumed through referenced assemblies carry their type and member
metadata in dependency payloads. The generator preserves the following
relationships when composing Machinery with DI, ECM and PA-DIM.
**VariableType data type restrictions.** `DependencyNode` carries a
VariableType's `DataType` and `ValueRank` alongside its base type, numeric id,
abstractness, fields and children. Consumers use these restrictions when
resolving variables such as OPC 40001-1's `EquipmentLife` and
`<LifetimeVariable>`, both typed by DI's `LifetimeVariableType`.
An optional node flag identifies the restriction metadata; payloads without
that flag remain readable. If required type metadata cannot be resolved,
`GetNodeStateClassName` reports the variable and its type in the error message.
**Method-state and declaration identities.** When a dependency child carries
both identities, the consumer links the declaration to its method-state node.
`ResolveMethodStateIdentity` follows that relationship to select the
producer's generated method-state class. This supports inherited method
re-declarations, including the DI `LockingServices` methods on OPC 34100 ECM.
**Inherited reference types and browse-name namespaces.** `DependencyChild`
carries `BrowseNameNamespace`, `ReferenceTypeName` and
`ReferenceTypeNamespace`. Directly instantiated derived types and nested
instances use the same inherited attributes. For example, the OPC 40001-2
members inherited from PA-DIM have these attributes:
| Member | Reference type | Browse-name namespace |
| --- | --- | --- |
| `ProcessValueType.SignalTag` | `HasProperty` | PA-DIM |
| `ProcessValueVariableType.EngineeringUnits` | `HasProperty` | OPC UA (namespace 0) |
The producer records a browse namespace only when it differs from the
declaring model and a reference type only when it differs from the member
kind's default. Per-child flag bits identify the optional metadata.
`MaterialiseDependencyChild` assigns the carried reference type when present,
or the same kind default that `ImportInstance` applies to locally declared
instances. Payloads without the optional metadata use those defaults.
`ApplyDefaultSignalTag` supplies the OPC 30081 default value for the mandatory
tag. The `opc.tcp` end-to-end tests exercise filtered browsing, and
`MachineryProcessValueTests.TheSignalKeepsThePadimBrowseNameAndTheNarrowerTypeAsync`
checks the inherited attributes.
## See also
- [Machinery samples](../samples/Machinery/README.md) — a simulated press that
exercises all five parts, and a client that walks it
- [Device Integration (DI) developer guide](DeviceIntegration.md)
- [ISA-95 developer guide](ISA95.md) — OPC 40001-3 sits on Job Control V2
- [Cross-assembly model dependencies](ModelDependencies.md)
- [Robotics developer guide](Robotics.md) — shares `Opc.Ua.IA`