mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 15:11:53 +08:00
# Description Remove stock phrasing and correct misleading or inconsistent technical documentation. The initial two passes covered 367 Markdown files, followed by an interactive review of 169 editorial flags. The U5 interpretation originally deferred in that review has since received the upstream Mantis answer recorded by #4843; remaining server/spec follow-ups stay explicit in the CTT log. - Correct package responsibilities, migration steps, API names, ownership, protocol descriptions and links. Keep bundled migration guides synchronized. Preserve historical conclusions as historical records with separate re-check notes. - Provide at least two rendered TOC levels throughout `docs/`. Convert appropriate architecture figures to Mermaid without changing code examples, transcripts or historical quotations. - Address the October 19 feedback with concrete setup/migration instructions, corrected certificate paths and PubSub names, shorter repeated caveats, consistent XML comments, and a full review of the Concepts guide. Retain #4839's API corrections. - Integrate #4841's runtime/performance guidance and review incoming/recent prose. Preserve dated measurements rather than claiming new benchmark runs; clarify allocation ratios, startup-only processor settings, JIT warm-up and the benchmark commit's scope. Missing raw measurement artifacts and repeat ranges remain tracked by #4842. - Merge master through `e8c5d800a` in `32c6394b7`. Resolve the CTT log conflict using the updated Mantis states and OPC answers, retaining historical framing and re-checks for C28/C32/C43. - Address the latest naming/index feedback in `adb5bf693`: rename the guides to `docs/WotCon.md` and `docs/WotNodeSetConversion.md`; move the provider and DataType-declaration contracts into the conversion guide and remove `docs/WoT.md`. Rename `samples/DI` to `samples/Di` and `samples/Isa95` to `samples/ISA95`. Add content-index READMEs to Di, FluentApi, ISA95, Lds and PubSub; update solution entries, project references, Docker/CI paths, test fixture paths and documentation links with exact casing. **WoT guide scope.** This branch retains the detailed conversion guide that #4758 had replaced with an anchor stub. Conversion limits, verified document sets, preservation rules and provider ownership now live together in `WotNodeSetConversion.md`; binding/runtime guidance remains in `WotBindings.md` and `WotCon.md`. The design-rationale/test-ledger prose was shortened, the oversized argument-table cell moved into its section, and existing anchors preserved. **SDK policy is a separate, explicit user-directed change.** `global.json` sets a stable minimum of `10.0.400`, `rollForward: latestMajor`, and `allowPrerelease: false`. Newer major SDKs are permitted; CI's explicit SDK pins are unchanged. README, GettingStarted, DeveloperGuide and Recycling instructions reflect the possible analyzer differences. The image-contract regression fix accepts compatible stable SDK images instead of requiring exact equality with the floor. The latest four rename/index threads are resolved after publication. Three older threads remain open: universal obsolete-removal timing, unconditional nonce-strength wording, and a blanket Vision NativeAOT claim. Existing implementation/evidence constraints are explained in their inline replies. The two-level TOCs remain as requested. ## Validation - `git diff --check` and staged diff checks: passed. The untouched upstream AutoID NodeSet retains its provenance bytes, including two existing license-header trailing spaces. - `.\.azurepipelines\validate-migration-plugin.ps1`: passed; 15 bundled migration documents and manifests synchronized. - Documentation/path validation: **101** documents retain two rendered TOC levels (**2,228** entries), with idempotent formatting. **52** new relative links and their anchors passed exact-case checks. No tracked references to the old guide/sample paths remain. Both relocated provider-contract sections are preserved in the destination. - `dotnet sln UA.slnx list`: passed; shows the renamed Di/ISA95 projects and the existing FluentApi/Lds/PubSub projects. Updated solution and pump ProjectReference paths also passed exact-case existence checks. - Latest focused commands, run from the repository root: ```powershell # Passed: 61 channel send-order and reconnect tests. dotnet test tests\Opc.Ua.Core.Tests\Opc.Ua.Core.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~UaSCBinaryChannelSendOrderTests|FullyQualifiedName~TcpReconnectKeyAgreementTests' --verbosity minimal # Passed: 5 pump datasheet tests; builds the renamed pump sample. dotnet test tests\Opc.Ua.Di.Tests\Opc.Ua.Di.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~PumpDatasheetConformanceTests' --verbosity minimal # Passed: 4 repository NodeSet path/round-trip tests, including the moved pump models. dotnet test tests\Opc.Ua.Types.Tests\Opc.Ua.Types.Tests.csproj -p:CustomTestTarget=net10.0 --filter 'FullyQualifiedName~RepositorySourceSurvivesSerializedLinkedPartitionsAsync' --verbosity minimal # Passed: build, zero warnings/errors. dotnet build samples\ISA95\MinimalIsa95Server\MinimalIsa95Server.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal # Passed: dependent sample-test project build, zero warnings/errors. dotnet build tests\Opc.Ua.WotCon.Samples.Tests\Opc.Ua.WotCon.Samples.Tests.csproj -p:CustomTestTarget=net10.0 -f net10.0 --verbosity minimal ``` Earlier focused integration checks passed 2,620 tests across AutoID, event queues/scheduling, source generation, MCP/package profiles and WoT. Both reviewed Mermaid diagrams rendered locally without network access. These checks are not a full solution run. The full solution has **not** been rerun for this review. Earlier `dotnet test UA.slnx -p:CustomTestTarget=net10.0 --verbosity quiet` did not pass: three AOT projects rejected VSTest and 20 Vision tests failed during graphics initialization with `OutOfMemoryException`. Remaining long-running tests were stopped at the user's request. A focused Vision rerun passed 558 tests and reproduced those 20 failures. The earlier `dotnet test UA.slnx -p:CustomTestTarget=net48 --verbosity quiet` was blocked by the former SDK pin and was not retried after the policy change. The user approved proceeding with those limitations documented; current remote CI is not claimed green. ## Related Issues - Follow-up #4842 tracks the requested 2.0.2+ benchmark/tuning and diagnostic-tool documentation refresh. #4841 supplies documentation and earlier results, not all newly requested measurements. - #4758 explains the conversion guide's earlier consolidation; the current provider contracts are now consolidated into `WotNodeSetConversion.md`. - #4839 supplies the reviewed API corrections; #4843 supplies the updated CTT issue record. - The original prose cleanup and SDK policy were user-requested without a source issue. No new runtime architecture is introduced by these documentation edits. ## 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. - [ ] 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: Roman Ettlinger <romanett98@gmail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1118 lines
47 KiB
Markdown
1118 lines
47 KiB
Markdown
# OPC UA Device Integration (DI)
|
|
|
|
> **Glossary:** In this document, **DI** refers to **OPC UA Device
|
|
> Integration** (companion specification [OPC 10000-100](https://reference.opcfoundation.org/specs/OPC-10000-100)),
|
|
> *not* to .NET Dependency Injection (covered in
|
|
> [DependencyInjection.md](DependencyInjection.md)). Wherever this
|
|
> document uses the unqualified initialism "DI" it always means the
|
|
> companion spec; .NET-DI mentions are spelled out as "Dependency
|
|
> Injection (.NET DI)" or appear with `Microsoft.Extensions.DependencyInjection`
|
|
> in context.
|
|
|
|
End-to-end developer guide for the `Opc.Ua.Di*` library trio shipped
|
|
with this repository and the `services.AddOpcUa()` hosting model that
|
|
plugs it together.
|
|
|
|
## Contents
|
|
|
|
- [Library layout](#library-layout)
|
|
- [Quick start](#quick-start)
|
|
- [Plain Device Integration (DI) server](#plain-device-integration-di-server)
|
|
- [Companion-spec server (Pumps + Device Integration)](#companion-spec-server-pumps--device-integration)
|
|
- [Client](#client)
|
|
- [Device builder](#device-builder)
|
|
- [Entry points](#entry-points)
|
|
- [Fluent surface](#fluent-surface)
|
|
- [Subclass support](#subclass-support)
|
|
- [Device sub-type extensions](#device-sub-type-extensions)
|
|
- [Software / Block / ConfigurableObject (§5.4 advanced sub-types)](#software--block--configurableobject-54-advanced-sub-types)
|
|
- [Lifetime indication (§10.6)](#lifetime-indication-106)
|
|
- [Support info (§5.15)](#support-info-515)
|
|
- [Hosting integration](#hosting-integration)
|
|
- [Server-side surface](#server-side-surface)
|
|
- [Context surface](#context-surface)
|
|
- [Architecture](#architecture)
|
|
- [Runtime conformance advertisement](#runtime-conformance-advertisement)
|
|
- [Client-side surface](#client-side-surface)
|
|
- [Lock service](#lock-service)
|
|
- [Components](#components)
|
|
- [Status codes](#status-codes)
|
|
- [Default lock service](#default-lock-service)
|
|
- [Binding to a `LockingServicesState`](#binding-to-a-lockingservicesstate)
|
|
- [End-to-end flow](#end-to-end-flow)
|
|
- [Hosting](#hosting)
|
|
- [Software update](#software-update)
|
|
- [Server-side: package store](#server-side-package-store)
|
|
- [Hosting](#hosting-1)
|
|
- [Client-side software update](#client-side-software-update)
|
|
- [Client helpers](#client-helpers)
|
|
- [DiLockClient](#dilockclient)
|
|
- [DiTopologyClient](#ditopologyclient)
|
|
- [Hosting registration](#hosting-registration)
|
|
- [What is supported (OPC 10000-100)](#what-is-supported-opc-10000-100)
|
|
- [Foundation — nameplates and topology elements](#foundation--nameplates-and-topology-elements)
|
|
- [Device tree](#device-tree)
|
|
- [Topology references](#topology-references-1)
|
|
- [Topology containers](#topology-containers)
|
|
- [Functional groups (§5.7)](#functional-groups-57)
|
|
- [Lock service (§10.5)](#lock-service-105)
|
|
- [Software update (§10.3)](#software-update-103)
|
|
- [Support info & lifetime indication](#support-info--lifetime-indication)
|
|
- [NAMUR alarms (§10.2)](#namur-alarms-102)
|
|
- [DataTypes & VariableTypes](#datatypes--variabletypes)
|
|
- [Not yet implemented](#not-yet-implemented)
|
|
- [See also](#see-also)
|
|
|
|
## Library layout
|
|
|
|
| Library | Role |
|
|
|---------|------|
|
|
| `Opc.Ua.Di` | Model assembly: source-generated NodeId tables, DataTypes, ObjectType client proxies, `AddOpcUaDi(NodeStateCollection)` extension. |
|
|
| `Opc.Ua.Di.Server` | Server: `DiNodeManager`, fluent topology/device builders, locking service, software-update package store, hosting integration. |
|
|
| `Opc.Ua.Di.Client` | Client: `DiDeviceClient`, `DiDiscoveryClient`, `DiTopologyClient`, `DiLockClient`, `SoftwareUpdateClient`, hosting integration. |
|
|
|
|
The running example is `samples/Di/PumpDeviceIntegrationServer`, a
|
|
companion-spec server that exposes a configurable number of fully
|
|
simulated `PumpType` instances. The sample uses the hand-written fluent
|
|
node-manager style for the common pump model and simulation wiring, then
|
|
uses `ConfigureDevicesFor` with the topology-element builder to add an
|
|
ad-hoc Diagnostics functional group to each pump. The simulated asset is
|
|
specified by its own product datasheet,
|
|
[`samples/Di/PumpDeviceIntegrationServer/DATASHEET.md`](../samples/Di/PumpDeviceIntegrationServer/DATASHEET.md),
|
|
which maps every nameplate field, engineering range and alarm trip point
|
|
to its OPC UA browse path.
|
|
|
|
## Quick start
|
|
|
|
### Plain Device Integration (DI) server
|
|
|
|
```csharp
|
|
using Microsoft.Extensions.DependencyInjection;
|
|
using Microsoft.Extensions.Hosting;
|
|
|
|
HostApplicationBuilder builder = Host.CreateApplicationBuilder(args);
|
|
builder.Services
|
|
.AddOpcUa()
|
|
.AddServer(o =>
|
|
{
|
|
o.ApplicationName = "MyDiServer";
|
|
o.EndpointUrls.Add("opc.tcp://localhost:48010/MyDiServer");
|
|
})
|
|
.AddOpcUaDi()
|
|
.ConfigureDevicesFor<DiNodeManager>(async ctx =>
|
|
{
|
|
var device = await ctx.CreateDeviceAsync(
|
|
new QualifiedName("Sensor_1", ctx.Manager.InstanceNamespaceIndex));
|
|
device.WithIdentification(id =>
|
|
{
|
|
id.Manufacturer = new LocalizedText("Acme");
|
|
id.SerialNumber = "SN-001";
|
|
id.DeviceClass = "Sensor";
|
|
});
|
|
});
|
|
|
|
await builder.Build().RunAsync();
|
|
```
|
|
|
|
### Companion-spec server (Pumps + Device Integration)
|
|
|
|
```csharp
|
|
builder.Services
|
|
.AddOpcUa()
|
|
.AddServer(o => { ... })
|
|
.AddNodeManager<Pumps.PumpNodeManagerFactory>()
|
|
// Pump factory already loads OPC UA Device Integration via its LoadPredefinedNodesAsync
|
|
// direct chain; do NOT call AddOpcUaDi() — that would
|
|
// double-register the OPC UA Device Integration namespace.
|
|
.ConfigureDevicesFor<Pumps.PumpNodeManager>(ctx =>
|
|
{
|
|
var manager = (Pumps.PumpNodeManager)ctx.Manager;
|
|
foreach (NodeId pumpNodeId in manager.PumpNodeIds)
|
|
{
|
|
ITopologyElementBuilder<PumpState> pump =
|
|
ctx.TopologyElement<PumpState>(pumpNodeId);
|
|
pump.WithFunctionalGroup(
|
|
new QualifiedName("Diagnostics", manager.InstanceNamespaceIndex),
|
|
group => group.Configure(node =>
|
|
node.WithProperty("LastError", Variant.From(string.Empty))));
|
|
}
|
|
|
|
return ValueTask.CompletedTask;
|
|
});
|
|
```
|
|
|
|
### Client
|
|
|
|
```csharp
|
|
builder.Services
|
|
.AddOpcUa()
|
|
.AddClient(o => { o.Configuration = ...; o.Session.Endpoint = ...; })
|
|
.AddOpcUaDi();
|
|
|
|
public sealed class MyAppService(
|
|
IDiDiscoveryService discovery,
|
|
Func<NodeId, CancellationToken, ValueTask<DiDeviceClient>> deviceFactory,
|
|
Func<CancellationToken, ValueTask<DiTopologyClient>> topologyFactory)
|
|
{
|
|
public async Task PrintAsync(CancellationToken ct)
|
|
{
|
|
DiTopologyClient topology = await topologyFactory(ct);
|
|
await foreach (TopologyEntry entry in topology.EnumerateDevicesAsync(ct))
|
|
{
|
|
DiDeviceClient device = await deviceFactory(entry.NodeId, ct);
|
|
DeviceIdentification id = await device.ReadIdentificationAsync(ct);
|
|
Console.WriteLine($"{id.Manufacturer} {id.Model} ({id.SerialNumber})");
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Device builder
|
|
|
|
The `IDeviceBuilder<TDevice>` fluent surface is the recommended way
|
|
to create and configure Device Integration device instances programmatically. It
|
|
lives in `Opc.Ua.Di.Server.Builders` and integrates with the broader
|
|
fluent API for node managers.
|
|
|
|
> **Runtime-created NodeIds use the server namespace.** `DiNodeManager`
|
|
> now mints NodeIds for device and topology instances in
|
|
> `InstanceNamespaceIndex`, the server application's namespace derived
|
|
> from `ApplicationConfiguration.ApplicationUri`, rather than in the
|
|
> DI or companion-spec namespace of the parent. This follows the
|
|
> companion-spec modelling rules (for example OPC 10000-100 §13.4 and
|
|
> OPC 40223): nodes not defined by a specification must not use the
|
|
> standard namespace. The local server defines such nodes in its Local
|
|
> Server URI namespace. BrowseNames defined by DI or another companion
|
|
> specification keep that specification's namespace. Use
|
|
> `InstanceNamespaceIndex` for application-defined instance names,
|
|
> functional groups, and properties.
|
|
|
|
### Entry points
|
|
|
|
All entry points live on `DiNodeManager`:
|
|
|
|
```csharp
|
|
// Default DeviceState organized by the Device Integration DeviceSet (or attached
|
|
// below whatever ResolveDefaultDeviceParent() returns).
|
|
ValueTask<IDeviceBuilder<DeviceState>> CreateDeviceAsync(
|
|
QualifiedName browseName,
|
|
NodeState? parent = null,
|
|
CancellationToken ct = default);
|
|
|
|
// Typed factory for ComponentType-derived companion-spec devices.
|
|
ValueTask<IDeviceBuilder<TDevice>> CreateDeviceAsync<TDevice>(
|
|
QualifiedName browseName,
|
|
NodeId typeDefinitionId,
|
|
Func<NodeState, TDevice> factory,
|
|
NodeState? parent = null,
|
|
CancellationToken ct = default)
|
|
where TDevice : ComponentState;
|
|
|
|
// Wrap an existing device.
|
|
IDeviceBuilder<TDevice> Device<TDevice>(TDevice device)
|
|
where TDevice : ComponentState;
|
|
IDeviceBuilder<TDevice> Device<TDevice>(NodeId nodeId)
|
|
where TDevice : ComponentState;
|
|
IDeviceBuilder<TDevice> DeviceByBrowseName<TDevice>(
|
|
QualifiedName browseName,
|
|
NodeState? parent = null)
|
|
where TDevice : ComponentState;
|
|
|
|
// Wrap an existing TopologyElementType-derived companion instance.
|
|
ITopologyElementBuilder<TElement> TopologyElement<TElement>(TElement element)
|
|
where TElement : TopologyElementState;
|
|
ITopologyElementBuilder<TElement> TopologyElement<TElement>(NodeId nodeId)
|
|
where TElement : TopologyElementState;
|
|
ITopologyElementBuilder<TElement> TopologyElementByBrowseName<TElement>(
|
|
QualifiedName browseName,
|
|
NodeState? parent = null)
|
|
where TElement : TopologyElementState;
|
|
```
|
|
|
|
`CreateDeviceAsync` performs five steps:
|
|
|
|
1. Resolves the parent (default: Device Integration `DeviceSet`; subclasses override
|
|
`ResolveDefaultDeviceParent()` — e.g. machinery managers can return
|
|
the `Machines` folder).
|
|
2. Uses `Organizes` for the well-known DI `DeviceSet`; explicit custom parents
|
|
retain `HasComponent`.
|
|
3. Fails fast if a child with the same browse name already exists
|
|
(`StatusCodes.BadBrowseNameDuplicated`).
|
|
4. Materializes the instance through the source-generated
|
|
`CreateInstanceOf<Type>` factory, such as
|
|
`CreateInstanceOfDeviceType`. The factory adds the type's **mandatory**
|
|
children and `HasInterface` references. For `DeviceType`, the children
|
|
are eight nameplate variables: `Manufacturer`, `Model`,
|
|
`HardwareRevision`, `SoftwareRevision`, `DeviceRevision`,
|
|
`DeviceManual`, `SerialNumber`, and `RevisionCounter`. It assigns
|
|
each variable a DI-namespace BrowseName.
|
|
|
|
Because the factory receives a BrowseName, it also rebases the subtree
|
|
onto per-instance NodeIds in the server's application namespace
|
|
(`DiNodeManager.InstanceNamespaceIndex`, the Local Server URI
|
|
namespace). This prevents instances of the same type from colliding
|
|
on the generator's type NodeIds. It also keeps application nodes out
|
|
of DI and other companion-spec namespaces. Finally, the factory sets
|
|
`BrowseName`, `SymbolicName`, and `DisplayName`, and stamps
|
|
`TypeDefinitionId`.
|
|
5. Calls the real `AsyncCustomNodeManager.AddPredefinedNodeAsync` so
|
|
the node create lifecycle, subscription wiring, type-tree registration,
|
|
and root-notifier propagation all happen exactly as for nodes loaded from
|
|
a NodeSet2.
|
|
|
|
Machinery-aware subclasses can additionally reference managed machines
|
|
from the OPC 40001 `Machines` entry point. `DiNodeManager`'s helper adds
|
|
an `Organizes` reference to the existing instance instead of re-parenting
|
|
it, so the same machine remains organized by `DeviceSet` while also being
|
|
browsable from `Machines`.
|
|
|
|
### Fluent surface
|
|
|
|
```csharp
|
|
IDeviceBuilder<TDevice>
|
|
.WithIdentification(action<DeviceIdentificationData>)
|
|
.WithIdentificationGroup(action<IFunctionalGroupBuilder>)
|
|
.WithConfigurationGroup(action<IFunctionalGroupBuilder>)
|
|
.WithMaintenanceGroup(action<IFunctionalGroupBuilder>)
|
|
.WithDiagnosticsGroup(action<IFunctionalGroupBuilder>)
|
|
.WithStatusGroup(action<IFunctionalGroupBuilder>)
|
|
.WithOperationalGroup(action<IFunctionalGroupBuilder>)
|
|
.WithStatisticsGroup(action<IFunctionalGroupBuilder>)
|
|
.WithOperationCountersGroup(action<IFunctionalGroupBuilder>)
|
|
.WithFunctionalGroup(QualifiedName name, action<IFunctionalGroupBuilder>)
|
|
.ConnectsTo(NodeId other)
|
|
.ConnectsToParent(NodeId parent)
|
|
.Configure(action<TDevice, ISystemContext>)
|
|
.WithDeviceHealth(DeviceHealthEnumeration) // extension; requires TDevice : DeviceState
|
|
```
|
|
|
|
`ITopologyElementBuilder<TElement>` is the parallel surface for
|
|
generated companion types that derive directly from
|
|
`TopologyElementType`. It exposes only common topology operations:
|
|
functional groups, `ConnectsTo` references, typed node access, and
|
|
raw state configuration. The internal device-builder implementation
|
|
reuses the same behavior without changing the existing public
|
|
`IDeviceBuilder<TDevice>` contract. Component nameplates, support
|
|
information, software update, lifetime indications, and
|
|
`DeviceHealth` remain on their narrower component/device builders.
|
|
|
|
#### Identification properties
|
|
|
|
`WithIdentification(action)` populates the standard Device Integration nameplate
|
|
properties on the device. The mutable `DeviceIdentificationData`
|
|
record holds:
|
|
|
|
- `Manufacturer` (`LocalizedText`, `IsNull` = unset)
|
|
- `ManufacturerUri` (`string?`)
|
|
- `Model` (`LocalizedText`, `IsNull` = unset)
|
|
- `HardwareRevision`, `SoftwareRevision`, `DeviceRevision`,
|
|
`ProductCode`, `DeviceManual`, `DeviceClass`, `SerialNumber`,
|
|
`ProductInstanceUri` (`string?`)
|
|
- `RevisionCounter` (`int?`)
|
|
|
|
Properties that exist as typed children on the device are updated in
|
|
place. The eight mandatory nameplate properties are always present
|
|
(materialised by `CreateInstanceOfDeviceType`), so setting them takes
|
|
the in-place path. Optional nameplate properties (`ManufacturerUri`,
|
|
`ProductCode`, `ProductInstanceUri`, `DeviceClass`) are created on
|
|
demand via `NodeState.AddProperty<T, VariantBuilder>`, re-qualified into
|
|
the **DI namespace** (never namespace 0), and registered with the
|
|
manager.
|
|
|
|
#### DI companion-spec compliance
|
|
|
|
Devices created through `CreateDeviceAsync` are modelled to the DI
|
|
companion spec: mandatory `DeviceType` children are present with
|
|
DI-namespace BrowseNames, the type's `HasInterface` references are
|
|
carried, and the software-update state-machine method children point at
|
|
their in-type `MethodDeclarationId`s. The model is intentionally **lean**
|
|
— OPC UA does not require *optional* interface members
|
|
(`IVendorNameplateType`/`ITagNameplateType`/`ISupportInfoType` members
|
|
such as `AssetId`, `ProductCode`, `DeviceTypeImage`, `Documentation`,
|
|
`ImageSet`, `ProtocolSupport`) to be instantiated, so they are omitted
|
|
unless the application adds them. An external compliance checker may still flag omitted optional members.
|
|
It may also report placeholder warnings for application-added
|
|
`HasComponent` children, such as a custom `Diagnostics` functional group
|
|
or the `SoftwareUpdate` facet. The specification permits these omissions
|
|
and extensions; they are not modelling errors.
|
|
|
|
#### Functional groups
|
|
|
|
Common Device Integration functional groups have typed builder methods.
|
|
Spec-defined groups keep their standard BrowseName namespace, for
|
|
example `Identification`, `Maintenance`, and `Operational` in the DI
|
|
namespace and `Events` in the Pumps namespace. Application-defined
|
|
groups use the server instance namespace; the diagnostics helper follows
|
|
that rule because the diagnostics group it creates is vendor-defined.
|
|
Arbitrary group names go through `WithFunctionalGroup(qualifiedName,
|
|
action)`.
|
|
Each call is idempotent: invoking the same accessor twice reuses the
|
|
existing group. The order of operations inside the builder is:
|
|
|
|
1. Create the `FunctionalGroupState` child (via
|
|
`TopologyElementState.AddIdentification` or `AddGroupIdentifier`).
|
|
2. Normalise BrowseName, SymbolicName, DisplayName, NodeId (via
|
|
`Context.NodeIdFactory`) and `TypeDefinitionId`.
|
|
3. Register the group with the manager via `AddPredefinedNodeAsync`.
|
|
4. Hand the `IFunctionalGroupBuilder` to the configure delegate.
|
|
|
|
```csharp
|
|
device.WithMaintenanceGroup(fg =>
|
|
{
|
|
fg.Organizes(device.Manufacturer!.NodeId);
|
|
fg.Node.WithProperty("LastMaintenanceDate", DateTime.UtcNow);
|
|
});
|
|
```
|
|
|
|
For vendor-specific groups, use the non-typed
|
|
`WithFunctionalGroup(QualifiedName, action)` overload. The browse
|
|
name's namespace index controls where the group lives; pass
|
|
`DiNodeManager.InstanceNamespaceIndex` for application-defined groups.
|
|
Reserve DI, Pumps, Machinery, or other companion-spec BrowseName
|
|
namespaces for nodes that are defined by those specifications. The
|
|
`PumpDeviceIntegrationServer` sample demonstrates this on `Pump #2`
|
|
to surface a custom `Diagnostics` folder:
|
|
|
|
```csharp
|
|
pump.WithFunctionalGroup(
|
|
new QualifiedName("Diagnostics", ctx.Manager.InstanceNamespaceIndex),
|
|
fg => fg.Configure(node =>
|
|
node.WithProperty("LastError", string.Empty)
|
|
.WithProperty("ErrorCount", 0)
|
|
.WithProperty("LastSelfTest", DateTime.UtcNow)));
|
|
```
|
|
|
|
#### Topology references
|
|
|
|
`ConnectsTo(NodeId)` adds a forward `Opc.Ua.Di.ReferenceTypes.ConnectsTo`
|
|
reference; `ConnectsToParent(NodeId)` adds the inverse. Useful for
|
|
declaring physical / logical topology relationships outside the
|
|
hierarchical address-space tree.
|
|
|
|
#### Device health
|
|
|
|
`WithDeviceHealth(DeviceHealthEnumeration)` is an extension method
|
|
constrained to `TDevice : DeviceState` (where the typed
|
|
`DeviceHealth` child variable exists). Throws
|
|
`StatusCodes.BadInvalidState` if the device was constructed without
|
|
the `DeviceHealth` child — typically because the factory was
|
|
`p => new DeviceState(p)` rather than the generator-produced
|
|
`CreateDeviceType` factory. Callers can pre-populate the child via
|
|
`Configure((dev, ctx) => ...)` to avoid the error.
|
|
|
|
### Subclass support
|
|
|
|
Companion-spec managers (`PumpNodeManager`, machinery managers)
|
|
inherit from `DiNodeManager` and gain the entire builder surface for
|
|
free. They typically override `ResolveDefaultDeviceParent()` to
|
|
return the companion-spec-specific container (`Machines` folder,
|
|
etc.) and supply their own typed factories to
|
|
`CreateDeviceAsync<TPumpType>(...)`.
|
|
|
|
## Device sub-type extensions
|
|
|
|
Three extension surfaces on top of `IDeviceBuilder<TDevice>` cover
|
|
optional OPC 10000-100 features:
|
|
|
|
### Software / Block / ConfigurableObject (§5.4 advanced sub-types)
|
|
|
|
`DeviceBuilderTypeExtensions` adds typed materialisation helpers for
|
|
the §5.4 sub-types that are not provided by `WithDeviceType` itself:
|
|
|
|
```csharp
|
|
SoftwareState sw = builder.AddSoftware(qn("Firmware"));
|
|
BlockState blk = builder.AddBlock(qn("InputBlock"));
|
|
ConfigurableObjectState cfg = builder.AddConfigurableObject(qn("DriverFolder"));
|
|
```
|
|
|
|
Each helper creates a typed child under the device, stamps a
|
|
manager-assigned `NodeId` via `INodeIdFactory`, sets the
|
|
`HasComponent` reference, registers the child via
|
|
`AddPredefinedNodeAsync`, and invokes an optional `configure`
|
|
callback. The returned typed state is ready for further
|
|
configuration through the standard `NodeState` APIs.
|
|
|
|
### Lifetime indication (§10.6)
|
|
|
|
`DeviceBuilderLifetimeExtensions.AddLifetimeIndication` creates a
|
|
`LifetimeVariableState` under the device with a
|
|
`LifetimeIndicationKind` classifier covering all six §10.6 indication
|
|
sub-types:
|
|
|
|
```csharp
|
|
device.AddLifetimeIndication(
|
|
qn("OperatingHours"),
|
|
LifetimeIndicationKind.Time,
|
|
startValue: 0.0);
|
|
|
|
device.AddLifetimeIndication(
|
|
qn("PartsProduced"),
|
|
LifetimeIndicationKind.NumberOfParts,
|
|
startValue: 0.0);
|
|
```
|
|
|
|
The classifier enum maps directly to OPC 10000-100 ObjectType ids:
|
|
|
|
| `LifetimeIndicationKind` | OPC UA ObjectType |
|
|
|---|---|
|
|
| `Time` | `TimeIndicationType` |
|
|
| `NumberOfParts` | `NumberOfPartsIndicationType` |
|
|
| `NumberOfUsages` | `NumberOfUsagesIndicationType` |
|
|
| `Length` | `LengthIndicationType` |
|
|
| `Diameter` | `DiameterIndicationType` |
|
|
| `SubstanceVolume` | `SubstanceVolumeIndicationType` |
|
|
|
|
`DeviceBuilderLifetimeExtensions.ResolveIndicationTypeId(kind,
|
|
namespaceUris)` resolves the matching NodeId at runtime when
|
|
applications need to materialise the indication classifier object
|
|
themselves.
|
|
|
|
### Support info (§5.15)
|
|
|
|
`DeviceBuilderSupportInfoExtensions.WithSupportInfo` creates an
|
|
`ISupportInfoState` child on the device (idempotent — re-uses the
|
|
existing instance on subsequent calls) and yields it through a
|
|
configure callback. The interface exposes
|
|
`DocumentationFiles` / `ImageSet` / `ProtocolSupport` folder
|
|
properties; consumers populate them by attaching `FileState` or
|
|
`BaseObjectState` children through the standard `NodeState` API.
|
|
File-backed children commonly use `FileState` wired to an
|
|
`IFileSystemProvider` from `src/Opc.Ua.Server/FileSystem`.
|
|
|
|
```csharp
|
|
device.WithSupportInfo(info =>
|
|
{
|
|
info.Description = new LocalizedText("Support information for device 1");
|
|
// attach FileState children for DocumentationFiles / ImageSet
|
|
// / ProtocolSupport folders here, using IFileSystemProvider
|
|
// for content backing.
|
|
});
|
|
```
|
|
|
|
## Hosting integration
|
|
|
|
Use `AddOpcUaDi()` and `ConfigureDevicesFor<TNodeManager>()` to add the
|
|
Device Integration library to the unified `AddOpcUa()`
|
|
`Microsoft.Extensions.DependencyInjection` hosting pattern.
|
|
|
|
### Server-side surface
|
|
|
|
`Microsoft.Extensions.DependencyInjection.OpcUaServerDiBuilderExtensions`
|
|
adds three methods on `IOpcUaServerBuilder`:
|
|
|
|
```csharp
|
|
IOpcUaServerBuilder AddOpcUaDi();
|
|
|
|
IOpcUaServerBuilder ConfigureDevicesFor<TNodeManager>(
|
|
Action<IDiPostSetupContext> configure)
|
|
where TNodeManager : DiNodeManager;
|
|
|
|
IOpcUaServerBuilder ConfigureDevicesFor<TNodeManager>(
|
|
Func<IDiPostSetupContext, ValueTask> configure)
|
|
where TNodeManager : DiNodeManager;
|
|
```
|
|
|
|
#### When to use `AddOpcUaDi()`
|
|
|
|
Call this when you want a **plain Device Integration (DI) server**
|
|
without any companion-spec subclass. It registers the
|
|
`DiNodeManagerFactory` so the hosted service stands up a pure
|
|
`DiNodeManager`.
|
|
|
|
```csharp
|
|
services.AddOpcUa()
|
|
.AddServer(o => { ... })
|
|
.AddOpcUaDi();
|
|
```
|
|
|
|
**Do NOT call `AddOpcUaDi()`** alongside a companion-spec factory
|
|
that already loads OPC UA Device Integration (e.g. `PumpNodeManagerFactory` which loads
|
|
Device Integration + Machinery + Pumps). The Device Integration namespace would be registered twice
|
|
and the OPC UA server may reject the duplicate. `AddOpcUaDi()` throws
|
|
on its second invocation to surface the misuse early.
|
|
|
|
#### `ConfigureDevicesFor<TNodeManager>(action)`
|
|
|
|
Registers a post-setup configurator targeted at a specific manager
|
|
type. The runner invokes each matching delegate after:
|
|
|
|
- predefined-node loading through `LoadPredefinedNodesAsync`;
|
|
- the manager's `ConfigureAsync(builder, ct)` wiring;
|
|
- authored-node registration through `RegisterAuthoredNodesAsync`;
|
|
- `CompleteConfigureAsync`.
|
|
|
|
The configurators run **before** `SealConfigurationAsync`. The base class
|
|
then seals the builder and starts the manager's registries.
|
|
|
|
Configurator-type matching follows `Type.IsAssignableFrom`. A
|
|
delegate targeting `DiNodeManager` runs against every Device Integration-derived
|
|
manager (including `PumpNodeManager`); a delegate targeting
|
|
`PumpNodeManager` will NOT run against a plain `DiNodeManager`.
|
|
|
|
Multiple `ConfigureDevicesFor<T>` calls accumulate and run in
|
|
registration order. **Exception semantics are fail-fast**: any
|
|
exception thrown by a configurator aborts hosted-server startup with
|
|
a diagnostic that identifies the failing index and target type.
|
|
|
|
### Context surface
|
|
|
|
The configurator receives an `IDiPostSetupContext`:
|
|
|
|
```csharp
|
|
public interface IDiPostSetupContext
|
|
{
|
|
DiNodeManager Manager { get; }
|
|
CancellationToken CancellationToken { get; }
|
|
T GetRequiredService<T>() where T : notnull;
|
|
|
|
ValueTask<IDeviceBuilder<DeviceState>> CreateDeviceAsync(...);
|
|
ValueTask<IDeviceBuilder<TDevice>> CreateDeviceAsync<TDevice>(...) where TDevice : ComponentState;
|
|
IDeviceBuilder<TDevice> Device<TDevice>(NodeId nodeId) where TDevice : ComponentState;
|
|
IDeviceBuilder<TDevice> DeviceByBrowseName<TDevice>(QualifiedName name, NodeState? parent = null)
|
|
where TDevice : ComponentState;
|
|
ITopologyElementBuilder<TElement> TopologyElement<TElement>(NodeId nodeId)
|
|
where TElement : TopologyElementState;
|
|
ITopologyElementBuilder<TElement> TopologyElementByBrowseName<TElement>(
|
|
QualifiedName name,
|
|
NodeState? parent = null)
|
|
where TElement : TopologyElementState;
|
|
}
|
|
```
|
|
|
|
The context **does not** expose a raw `IServiceProvider`. Use
|
|
`GetRequiredService<T>()` to resolve application services
|
|
(intentionally narrow to discourage service-locator anti-patterns
|
|
and lifetime traps).
|
|
|
|
### Architecture
|
|
|
|
| Component | Lifetime | Where |
|
|
|-----------|----------|-------|
|
|
| `IDiPostSetupRunner` | Singleton (registered by `AddOpcUaDi`/`ConfigureDevicesFor`) | `Opc.Ua.Di.Server.Hosting` |
|
|
| `IDiPostSetupConfigurator` | Singleton (one per `ConfigureDevicesFor` call) | `Opc.Ua.Di.Server.Hosting` |
|
|
| `DiNodeManagerFactory` / `PumpNodeManagerFactory` | Singleton (.NET-DI-aware ctor injects runner) | server / app |
|
|
| `DiNodeManager` / `PumpNodeManager` | Per-server startup (factory passes runner) | app |
|
|
|
|
The runner is injected into the manager via the factory. The base
|
|
`DiNodeManager` auto-invokes it at the end of its own
|
|
`CreateAddressSpaceAsync` for every concrete subclass:
|
|
|
|
- The base `DiNodeManager.CreateAddressSpaceAsync` calls
|
|
`LoadPredefinedNodesAsync` → `CreateFluentBuilder(...)` →
|
|
`ConfigureAsync(builder, ct)` → `RegisterAuthoredNodesAsync` →
|
|
`CompleteConfigureAsync` → `PostSetupRunner.RunAsync(this, ct)` →
|
|
`SealConfigurationAsync(builder, ct)` in that order. Sealing comes last because the
|
|
fluent registries (simulations, event sources) are owned by the manager,
|
|
not by a single builder, and sealing starts them — a post-setup
|
|
configurator must still be able to register a simulation loop.
|
|
- Subclasses (e.g. `PumpNodeManager`) override the
|
|
`protected virtual ValueTask ConfigureAsync(INodeManagerBuilder, CancellationToken)`
|
|
hook to materialise instances and drive the fluent
|
|
`INodeManagerBuilder`. Because the hook is awaited, asynchronous
|
|
setup and synchronous wiring live in one method; the base class owns
|
|
node registration and sealing. The runner fires automatically before
|
|
`SealConfigurationAsync` — subclasses do not need to call
|
|
`PostSetupRunner.RunAsync` themselves.
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
autonumber
|
|
participant Host as Hosting / factory
|
|
participant Base as DiNodeManager
|
|
participant Sub as Subclass, e.g. PumpNodeManager
|
|
participant B as NodeManagerBuilder
|
|
participant Run as IDiPostSetupRunner
|
|
|
|
Host->>Base: CreateAddressSpaceAsync(externalReferences, ct)
|
|
Base->>Base: await LoadPredefinedNodesAsync
|
|
Note over Base: predefined nodes loaded, type tree wired
|
|
Base->>B: CreateFluentBuilder(InstanceNamespaceIndex)
|
|
Note over B: attached to the manager's event-source<br/>and simulation registries
|
|
|
|
Base->>Sub: await ConfigureAsync(builder, ct)
|
|
Note over Sub: asynchronous setup and synchronous wiring<br/>in one awaited method
|
|
|
|
Base->>Base: await RegisterAuthoredNodesAsync(builder, ct)
|
|
Base->>Base: await CompleteConfigureAsync(externalReferences, ct)
|
|
|
|
Base->>Run: await RunAsync(this, ct)
|
|
Note over Run: configurators still register simulation<br/>loops and event sources here
|
|
|
|
Base->>B: await SealConfigurationAsync(builder, ct)
|
|
Note over B: drain staged root notifiers,<br/>then start the simulations
|
|
```
|
|
|
|
Sealing is the last step for a reason: the fluent registries belong to
|
|
the manager rather than to one builder, and sealing starts them. Sealing
|
|
before `RunAsync` would leave the post-setup configurators unable to
|
|
register a simulation loop of their own.
|
|
|
|
### Runtime conformance advertisement
|
|
|
|
`DiNodeManager` implements `IConformanceContributor`, so the hosted
|
|
server publishes the DI facets and conformance units that are actually
|
|
wired at startup. The server merges these contributions into
|
|
`Server/ServerCapabilities/ServerProfileArray` and
|
|
`Server/ServerCapabilities/ConformanceUnits` alongside the base server
|
|
profiles.
|
|
|
|
Companions composed into the same manager can call
|
|
`RegisterConformanceContributor(IConformanceContributor)` instead of creating
|
|
a subclass solely to append units. The registration is idempotent by contributor
|
|
instance, preserves the DI units, and reevaluates the contributor on each publication.
|
|
Contributors must not refer back to their owning manager. The
|
|
[Machine Vision integration](Vision.md) uses this path for Machinery-hosted
|
|
released models; it does not invent profile URIs.
|
|
|
|
The conformance units follow what is built:
|
|
|
|
| Runtime condition | Conformance units |
|
|
| --- | --- |
|
|
| DI `DeviceSet` exists | `DI DeviceTopology` |
|
|
| A `LockingServicesState` has `InitLock`, `RenewLock`, or `ExitLock` wired | `DI Locking` |
|
|
| The same lock service also wires `BreakLock` | `DI BreakLocking` |
|
|
| Any software-update loading mode is recorded | `DI SU Software Update`, `DI SU PrepareForUpdate`, `DI SU Resume Update` |
|
|
| `SoftwareLoadingMode.Direct` | `DI SU DirectLoading` |
|
|
| `SoftwareLoadingMode.Cached` | `DI SU CachedLoading` |
|
|
|
|
A facet is advertised only while every conformance unit the DI 1.05
|
|
profile database marks mandatory for it is advertised:
|
|
|
|
| Server profile URI | Mandatory conformance units | Advertised |
|
|
| --- | --- | --- |
|
|
| `http://opcfoundation.org/UA-Profile/DI/Server/DeviceIntegrationHost` | `DI DeviceTopology`, `DI Offline` | No: offline and online device representations are not built |
|
|
| `http://opcfoundation.org/UA-Profile/DI/Server/Locking` | `DI Locking` | When a lock service is wired |
|
|
| `http://opcfoundation.org/UA-Profile/DI/Server/SoftwareUpdateBase` | `DI SU Software Update` | When a software update is attached |
|
|
| `http://opcfoundation.org/UA-Profile/DI/Server/FileSystemLoading` | Software Update Base, `DI SU FileSystem Loading`, `DI SU Installation for File System` | No: `Package` builds a `PackageLoadingType`, not a `FileSystemLoadingType`, and the installation offers `InstallSoftwarePackage` |
|
|
| `http://opcfoundation.org/UA-Profile/DI/Server/DirectLoading` | Software Update Base, `DI SU DirectLoading`, `DI SU UpdateStatus` | No: the `UpdateStatus` variable is not built |
|
|
| `http://opcfoundation.org/UA-Profile/DI/Server/CachedLoading` | Software Update Base, `DI SU CachedLoading`, `DI SU Installation for Cached Loading`, `DI SU UpdateStatus` | No: the `UpdateStatus` variable is not built and the installation offers `InstallFiles` |
|
|
|
|
### Client-side surface
|
|
|
|
`Microsoft.Extensions.DependencyInjection.OpcUaClientDiBuilderExtensions`
|
|
adds a single extension method on `IOpcUaClientBuilder`:
|
|
|
|
```csharp
|
|
IOpcUaClientBuilder AddOpcUaDi();
|
|
```
|
|
|
|
This registers the following services and client factories over the lazy
|
|
`ManagedSession` accessor produced by `AddClient(...)`:
|
|
|
|
- `IDiDiscoveryService` — recursive device discovery from the
|
|
`Objects` folder; streams `DeviceEntry` records as
|
|
`IAsyncEnumerable<DeviceEntry>` so callers can begin processing
|
|
before the browse completes.
|
|
- `Func<NodeId, CancellationToken, ValueTask<DiDeviceClient>>` —
|
|
validates a device NodeId and returns a typed client.
|
|
- `Func<NodeId, CancellationToken, ValueTask<DiLockClient>>` —
|
|
client wrapper for `LockingServicesType.InitLock` /
|
|
`RenewLock` / `ExitLock` / `BreakLock`.
|
|
- `Func<CancellationToken, ValueTask<DiTopologyClient>>` —
|
|
topology browser for the Device Integration well-known folders.
|
|
- `Func<NodeId, CancellationToken, ValueTask<SoftwareUpdateClient>>` —
|
|
software-version reader for a SoftwareUpdate instance.
|
|
- `Func<NodeId, CancellationToken, ValueTask<DiTransferClient>>` —
|
|
client wrapper for `TransferServicesType`.
|
|
|
|
Requires `AddClient(...)` to be called first — the extension throws
|
|
`InvalidOperationException` if the managed-session accessor is
|
|
missing when one of the factories is resolved.
|
|
|
|
## Lock service
|
|
|
|
The lock service implements the OPC 10000-100 §10.5 locking facet.
|
|
It tracks ownership of every `TopologyElementType.Lock` instance in
|
|
the address space, enforces configurable timeouts, releases locks
|
|
automatically when the owning session closes, and exposes the four
|
|
spec-defined methods through the typed `LockingServicesState` proxy.
|
|
|
|
### Components
|
|
|
|
| Type | Purpose |
|
|
|------|---------|
|
|
| `ILockService` | Application-facing facade: `InitLock`, `RenewLock`, `ExitLock`, `BreakLock`, `GetState`. Thread-safe. |
|
|
| `LockState` | Snapshot record (Locked, LockingClient, LockingUser, RemainingLockTimeSeconds). |
|
|
| `LockStatus` | OPC 10000-100 status-code constants. |
|
|
| `DefaultLockService` | Default in-memory implementation backed by a `ConcurrentDictionary`. |
|
|
| `LockingServicesExtensions.BindToLockService` | Wires the four generated `*MethodState.OnCall` handlers on a `LockingServicesState` instance through an `ILockService`. |
|
|
|
|
Namespaces: `Opc.Ua.Di.Server.Locking`.
|
|
|
|
### Status codes
|
|
|
|
Following OPC 10000-100 §10.5:
|
|
|
|
| Constant | Value | Returned from |
|
|
|----------|-------|---------------|
|
|
| `LockStatus.Ok` | 0 | All methods (success) |
|
|
| `LockStatus.AlreadyLocked` | 1 | `InitLock` only |
|
|
| `LockStatus.CouldNotLock` | 2 | `InitLock` only |
|
|
| `LockStatus.NotLocked` | 1 | `RenewLock` / `ExitLock` / `BreakLock` |
|
|
| `LockStatus.WrongClient` | 2 | `RenewLock` / `ExitLock` |
|
|
|
|
### Default lock service
|
|
|
|
```csharp
|
|
var lockService = new DefaultLockService(
|
|
lockDuration: TimeSpan.FromMinutes(5), // optional, defaults to 5 min
|
|
timeProvider: TimeProvider.System); // optional, for tests
|
|
|
|
// Hook session-close so locks held by a disconnecting session are
|
|
// released automatically.
|
|
lockService.AttachToSessionManager(server.SessionManager);
|
|
```
|
|
|
|
Lock ownership is keyed by `ServerSystemContext.SessionId`. When the
|
|
service is invoked from a test (`SystemContext` rather than
|
|
`ServerSystemContext`), it falls back to a synthetic session id
|
|
derived from `ISystemContext.UserId` so unit tests can still
|
|
distinguish callers.
|
|
|
|
### Binding to a `LockingServicesState`
|
|
|
|
The Device Integration spec defines a `Lock` child on every `TopologyElementType`
|
|
that implements four method invocations. Wire them through your
|
|
service:
|
|
|
|
```csharp
|
|
LockingServicesState lockNode = device.Lock!;
|
|
lockNode.BindToLockService(elementId: device.NodeId, service: lockService);
|
|
```
|
|
|
|
`BindToLockService` reads `lockNode.InitLock`, `RenewLock`,
|
|
`ExitLock`, `BreakLock` and replaces their `OnCall` handlers with
|
|
delegates that route into the `ILockService`. The `elementId`
|
|
argument keys the service's internal dictionary — typically the
|
|
topology element's own NodeId so two different devices can hold
|
|
independent locks.
|
|
|
|
### End-to-end flow
|
|
|
|
1. Client calls `InitLock(context: "tag")` on a device's Lock.
|
|
2. The method's `OnCall` runs the bound `ILockService.InitLock`,
|
|
which checks the per-device record:
|
|
- If unlocked / expired → record `(SessionId, "tag", UserId,
|
|
now + duration)`, return `Ok` (0).
|
|
- If already locked by another session → return `AlreadyLocked` (1).
|
|
3. `RenewLock` (same session) extends the expiry timestamp; from a
|
|
different session it returns `WrongClient` (2).
|
|
4. `ExitLock` removes the record (subject to ownership check).
|
|
5. `BreakLock` removes the record regardless of ownership; intended
|
|
for administrative recovery.
|
|
6. If the owning session closes (server-emitted `SessionClosing`
|
|
event), the `DefaultLockService` walks its records and releases
|
|
anything that was held by that session.
|
|
|
|
### Hosting
|
|
|
|
Register the service as a singleton through standard
|
|
`Microsoft.Extensions.DependencyInjection`:
|
|
|
|
```csharp
|
|
services.AddSingleton<ILockService, DefaultLockService>();
|
|
```
|
|
|
|
Then attach the session-close hook from inside a
|
|
`ConfigureDevicesFor<TNodeManager>` configurator once the server's
|
|
`SessionManager` is available:
|
|
|
|
```csharp
|
|
.ConfigureDevicesFor<DiNodeManager>(ctx =>
|
|
{
|
|
ILockService svc = ctx.GetRequiredService<ILockService>();
|
|
if (svc is DefaultLockService defaultSvc)
|
|
{
|
|
defaultSvc.AttachToSessionManager(ctx.Manager.Server.SessionManager);
|
|
}
|
|
foreach (DeviceState device in ctx.Manager.PredefinedNodes
|
|
.Values.OfType<DeviceState>())
|
|
{
|
|
device.Lock?.BindToLockService(device.NodeId, svc);
|
|
}
|
|
});
|
|
```
|
|
|
|
## Software update
|
|
|
|
The software-update facet implements OPC 10000-100 workflows for loading,
|
|
preparation, installation, power cycle, and confirmation. Attach it with
|
|
`WithSoftwareUpdate`; application handlers supply device-specific behavior.
|
|
The SDK supplies the state-machine wiring and file-transfer infrastructure.
|
|
|
|
### Server-side: package store
|
|
|
|
`ISoftwarePackageStore` stores package metadata and payloads.
|
|
`ISoftwareFolder` maintains each device's Current / Previous / Future versions.
|
|
Both have memory and file-system implementations. See
|
|
[Software Update storage abstractions](SoftwareUpdate.md#storage-abstractions)
|
|
for their contracts and persistence choices.
|
|
|
|
#### Surface
|
|
|
|
Use the [package-store contract](SoftwareUpdate.md#package-store-contract)
|
|
for operations, metadata, and stream ownership. The Software Update guide also
|
|
owns `WithSoftwareUpdate` options and handler hooks.
|
|
|
|
#### `FileSystemPackageStore` layout
|
|
|
|
Use the store through `ISoftwarePackageStore`, rather than modifying its backing
|
|
files directly. See [file-system layout and provider composition](SoftwareUpdate.md#file-system-layout-and-provider-composition)
|
|
for the file-system provider and persistence options.
|
|
|
|
#### Composing with `IFileSystemProvider`
|
|
|
|
Reuse the configured provider as described in
|
|
[provider composition](SoftwareUpdate.md#file-system-layout-and-provider-composition).
|
|
|
|
### Hosting
|
|
|
|
Register the package store with the host and attach it to the device's
|
|
software-update facet. See the [hosted-server walkthrough](SoftwareUpdate.md#hosted-server-walkthrough)
|
|
and [package seeding example](SoftwareUpdate.md#seeding-a-package).
|
|
|
|
### Client-side software update
|
|
|
|
`SoftwareUpdateClient` reads versions, uploads packages, and exposes typed
|
|
state-machine operations. The usual workflow is upload, prepare, install, and
|
|
confirm; supported steps depend on the device's advertised update capabilities.
|
|
See [uploading a package](SoftwareUpdate.md#uploading-a-package) and
|
|
[typed state-machine operations](SoftwareUpdate.md#typed-part-16-state-machine-surface)
|
|
for runnable call sequences.
|
|
|
|
The DI client integration registers
|
|
`Func<NodeId, CancellationToken, ValueTask<SoftwareUpdateClient>>` through
|
|
`services.AddOpcUa().AddClient(...).AddOpcUaDi()`.
|
|
|
|
## Client helpers
|
|
|
|
Beyond `DiDeviceClient` and `DiDiscoveryClient`, the
|
|
`Opc.Ua.Di.Client` library ships three typed client wrappers that
|
|
compose the standard OPC UA `Session` surface.
|
|
|
|
| Helper | Purpose |
|
|
|--------|---------|
|
|
| `DiLockClient` | Wraps the four `LockingServicesType` methods. |
|
|
| `DiTopologyClient` | Browses `DeviceSet`, `NetworkSet`, `DeviceTopology`. |
|
|
| `SoftwareUpdateClient` | Reads the software-version property of a `SoftwareUpdateType` instance. |
|
|
|
|
All three live in `Opc.Ua.Di.Client` and are registered automatically
|
|
when applications call `services.AddOpcUa().AddClient(...).AddOpcUaDi()`
|
|
on the client-side builder.
|
|
|
|
### DiLockClient
|
|
|
|
```csharp
|
|
public sealed class DiLockClient
|
|
{
|
|
public DiLockClient(ISession session, NodeId lockNodeId, ITelemetryContext telemetry);
|
|
public ValueTask<int> InitLockAsync(string context, CancellationToken ct = default);
|
|
public ValueTask<int> RenewLockAsync(CancellationToken ct = default);
|
|
public ValueTask<int> ExitLockAsync(CancellationToken ct = default);
|
|
public ValueTask<int> BreakLockAsync(CancellationToken ct = default);
|
|
}
|
|
```
|
|
|
|
Each method invokes the corresponding `LockingServicesType.{Method}`
|
|
generated NodeId on the server and returns the integer status code
|
|
(see the [`LockStatus` table](#status-codes) for the OPC spec values).
|
|
|
|
The constructor takes the NodeId of the `Lock` instance — typically
|
|
the `device.Lock` child, not the device itself. Resolve it via
|
|
`Session.TranslateBrowsePathsToNodeIdsAsync` or by browsing the
|
|
device's `HasComponent` references for a child named `"Lock"`.
|
|
|
|
```csharp
|
|
DiLockClient lockClient = new(session, lockNodeId, telemetry);
|
|
int status = await lockClient.InitLockAsync("client-tag");
|
|
if (status == LockStatus.Ok)
|
|
{
|
|
try
|
|
{
|
|
// Do work that requires exclusive access.
|
|
}
|
|
finally
|
|
{
|
|
await lockClient.ExitLockAsync();
|
|
}
|
|
}
|
|
```
|
|
|
|
### DiTopologyClient
|
|
|
|
```csharp
|
|
public sealed class DiTopologyClient
|
|
{
|
|
public DiTopologyClient(ISession session, ITelemetryContext telemetry);
|
|
public NodeId DeviceSetId { get; }
|
|
public NodeId NetworkSetId { get; }
|
|
public NodeId DeviceTopologyId { get; }
|
|
|
|
public IAsyncEnumerable<TopologyEntry> EnumerateDevicesAsync(CancellationToken ct = default);
|
|
public IAsyncEnumerable<TopologyEntry> EnumerateNetworksAsync(CancellationToken ct = default);
|
|
public IAsyncEnumerable<TopologyEntry> EnumerateChildrenAsync(NodeId parentNodeId, CancellationToken ct = default);
|
|
}
|
|
|
|
public sealed record TopologyEntry(
|
|
NodeId NodeId,
|
|
string DisplayName,
|
|
NodeId TypeDefinitionId,
|
|
QualifiedName BrowseName);
|
|
```
|
|
|
|
`EnumerateDevicesAsync` and `EnumerateNetworksAsync` browse the
|
|
hierarchical references of `DeviceSet` and `NetworkSet` and return
|
|
the direct children that are Objects. `EnumerateChildrenAsync`
|
|
provides the same surface for arbitrary nodes — useful when walking
|
|
the `DeviceTopology` tree.
|
|
|
|
```csharp
|
|
DiTopologyClient topology = new(session, telemetry);
|
|
await foreach (TopologyEntry device in topology.EnumerateDevicesAsync())
|
|
{
|
|
Console.WriteLine($"{device.BrowseName}: {device.DisplayName}");
|
|
}
|
|
```
|
|
|
|
### Hosting registration
|
|
|
|
When `services.AddOpcUa().AddClient(o => { ... }).AddOpcUaDi()` is
|
|
called, the following factories are registered as singletons:
|
|
|
|
```csharp
|
|
Func<NodeId, CancellationToken, ValueTask<DiDeviceClient>>
|
|
Func<NodeId, CancellationToken, ValueTask<DiLockClient>>
|
|
Func<CancellationToken, ValueTask<DiTopologyClient>>
|
|
Func<NodeId, CancellationToken, ValueTask<SoftwareUpdateClient>>
|
|
IDiDiscoveryService
|
|
```
|
|
|
|
Inject the factory you need into your application services; the
|
|
factory opens (or reuses) the lazy `ManagedSession` on first call.
|
|
|
|
```csharp
|
|
public sealed class MyMonitor(
|
|
Func<CancellationToken, ValueTask<DiTopologyClient>> topologyFactory,
|
|
Func<NodeId, CancellationToken, ValueTask<DiDeviceClient>> deviceFactory,
|
|
Func<NodeId, CancellationToken, ValueTask<DiLockClient>> lockFactory)
|
|
{
|
|
public async Task ScanAsync(CancellationToken ct)
|
|
{
|
|
DiTopologyClient topo = await topologyFactory(ct);
|
|
await foreach (TopologyEntry e in topo.EnumerateDevicesAsync(ct))
|
|
{
|
|
DiDeviceClient dev = await deviceFactory(e.NodeId, ct);
|
|
DeviceIdentification id = await dev.ReadIdentificationAsync(ct);
|
|
// ...
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## What is supported (OPC 10000-100)
|
|
|
|
References are to the OPC 10000-100 (DI v1.05) specification sections.
|
|
|
|
### Foundation — nameplates and topology elements
|
|
|
|
- `TopologyElementType` (§5.2) — abstract base exposed through
|
|
`ITopologyElementBuilder<TElement>`.
|
|
- `IVendorNameplateType` (§5.10) exposes `Manufacturer`, `Model`,
|
|
`SerialNumber`, `HardwareRevision`, `SoftwareRevision`, `DeviceRevision`,
|
|
`DeviceManual`, `DeviceClass`, `ProductInstanceUri`, and `ProductCode`.
|
|
Populate these properties with `IDeviceBuilder.WithIdentification(...)`.
|
|
- `ITagNameplateType` (§5.11) — AssetId, ComponentName, DeviceRevision. Populated by the same builder.
|
|
- `IDeviceHealthType` (§5.12) — DeviceHealth enum plus the four NAMUR alarm references.
|
|
- `IAssetLocationIndicationType` (§5.13) — `StartLocationIndication` / `StopLocationIndication` methods.
|
|
- `IOperationCounterType` (§5.14) — PowerOnDuration, OperationDuration, EstimatedReturnedOperationDuration, EstimatedReturnedPowerOnDuration.
|
|
|
|
### Device tree
|
|
|
|
- `ComponentType` (§5.3) — abstract; materialised via `IDeviceBuilder`.
|
|
- `DeviceType` (§5.4) — `IDeviceBuilder.WithDeviceType<TDeviceState>(factory)`.
|
|
- `SoftwareType`, `BlockType`, `ConfigurableObjectType` (§5.4 advanced sub-types) — `IDeviceBuilder.AddSoftware`, `AddBlock`, `AddConfigurableObject`.
|
|
|
|
### Topology references
|
|
|
|
- `ConnectsTo` (§5.6.2) — `ITopologyElementBuilder.ConnectsTo(other)`.
|
|
- `ConnectsToParent` (§5.6.3) —
|
|
`ITopologyElementBuilder.ConnectsToParent(other)`.
|
|
- `IsOnline` (§5.6.4) — exposed for online-component wiring.
|
|
- `UpdateParent`, `CanUpdate` (§5.6.5–§5.6.6) — used by software update.
|
|
|
|
### Topology containers
|
|
|
|
- `DeviceSet` (§6.2) — well-known instance, auto-created by `DiNodeManager` under `ObjectsFolder`; `IDeviceBuilder` parents devices here by default.
|
|
- `NetworkSet` (§6.3) — well-known instance.
|
|
- `DeviceTopology` (§6.4) — well-known instance; topology references attach here.
|
|
- `NetworkType`, `ConnectionPointType`, `ProtocolType` (§6.5–§6.7).
|
|
|
|
### Functional groups (§5.7)
|
|
|
|
All eight well-known Device Integration functional groups are exposed
|
|
through typed methods on `ITopologyElementBuilder`; device builders
|
|
inherit the same surface:
|
|
|
|
- `Identification`, `Configuration`, `Maintenance`, `Diagnostics`, `Statistics`, `Status`, `Operational`, `OperationCounters`.
|
|
|
|
Custom groups go through `WithFunctionalGroup(qualifiedName, action)`.
|
|
|
|
### Lock service (§10.5)
|
|
|
|
- `LockingServicesType` and four method types:
|
|
`InitLockMethodType`, `RenewLockMethodType`, `ExitLockMethodType`, and
|
|
`BreakLockMethodType`. `ILockService` and `DefaultLockService` wire these
|
|
methods and provide session ownership, configurable timeouts, and cleanup
|
|
when a session closes.
|
|
|
|
### Software update (§10.3)
|
|
|
|
- `SoftwareUpdateType` — orchestrates the loading + state-machine wiring.
|
|
- `SoftwareLoadingType`, `PackageLoadingType` (§10.3.4) — abstract bases.
|
|
- `DirectLoadingType`, `CachedLoadingType`, `FileSystemLoadingType` (§10.3.4) — the three loading variants.
|
|
- `SoftwareVersionType` (§10.3.6) — Manufacturer, ProductInstanceUri, SoftwareRevision, PatchIdentifiers, ReleaseDate, ChangeLog, Hash.
|
|
- Generated proxies for `PrepareForUpdateStateMachineType` (§10.3.7),
|
|
`InstallationStateMachineType` (§10.3.8),
|
|
`PowerCycleStateMachineType` (§10.3.9), and
|
|
`ConfirmationStateMachineType` (§10.3.10), driven by the application.
|
|
- Storage abstraction: `ISoftwarePackageStore` with `MemoryPackageStore` and `FileSystemPackageStore` implementations.
|
|
|
|
### Support info & lifetime indication
|
|
|
|
- `ISupportInfoType` (§5.15) — `IDeviceBuilder.WithSupportInfo(configure)`. Backing folders `DocumentationFiles` / `ImageSet` / `ProtocolSupport` accept `FileState` / `BaseObjectState` children; file content commonly backed by `IFileSystemProvider`.
|
|
- `LifetimeVariableType` + 6 indication sub-types (§10.6) — `IDeviceBuilder.AddLifetimeIndication(kind, …)`. Classifier kinds: `Time`, `NumberOfParts`, `NumberOfUsages`, `Length`, `Diameter`, `SubstanceVolume`.
|
|
|
|
### NAMUR alarms (§10.2)
|
|
|
|
- `DeviceHealthDiagnosticAlarmType` (abstract) and the four concrete alarm types (`FailureAlarmType`, `CheckFunctionAlarmType`, `OffSpecAlarmType`, `MaintenanceRequiredAlarmType`) — generated proxies wired via the fluent `IAlarmBuilder<TState>` + `ActivatesAlarm` patterns from `src/Opc.Ua.Server/Fluent`.
|
|
|
|
### DataTypes & VariableTypes
|
|
|
|
The `Opc.Ua.Di` model library includes these source-generated Device
|
|
Integration DataTypes:
|
|
|
|
- `DeviceHealthEnumeration`
|
|
- `SoftwareClass`
|
|
- `LocationIndicationType`
|
|
- `SoftwareVersionFileType`
|
|
- `UpdateBehavior`
|
|
- `FetchResultDataType`
|
|
- `TransferResultErrorDataType`
|
|
- `TransferResultDataDataType`
|
|
- `ParameterResultDataType`
|
|
|
|
### Not yet implemented
|
|
|
|
- `TransferServicesType` (§10.4) — parameter set transfer.
|
|
- Offline and online device representations (§6.3) — needed for the
|
|
`DI Offline` conformance unit and so for the DeviceIntegrationHost facet.
|
|
- The `UpdateStatus` variable of `SoftwareUpdateType` (§8.4.1.8) and a
|
|
`FileSystemLoadingType` loading mode — needed for the Direct, Cached and
|
|
FileSystem Loading facets (see
|
|
[Runtime conformance advertisement](#runtime-conformance-advertisement)).
|
|
|
|
## See also
|
|
|
|
- [FileSystemClient](FileSystemClient.md) — file-transfer client used by `SoftwareUpdateClient` and `FileSystemPackageStore`.
|
|
- [Source-generated NodeManagers](NodeManagers.md#source-generated-node-managers) — the underlying source generator and fluent API.
|