Device Integration (DI)
June 4, 2026 · View on GitHub
Glossary: "DI" throughout this document means OPC UA Device Integration (OPC 10000-100), not .NET Dependency Injection.
This document explains how to expose the OPC 10000-100 §10.3 software-update facet on a Device Integration (DI) device, what address-space surface it creates, and how clients drive it.
Address-space layout
WithSoftwareUpdate(...) materialises one
SoftwareUpdateType instance per device with the following children:
{device} (ComponentState / DeviceState)
└─ SoftwareUpdate SoftwareUpdateType (OPC-DI i=1)
├─ Loading {Package|Direct|Cached}LoadingType
│ (default: PackageLoadingType i=137)
├─ PrepareForUpdate PrepareForUpdateStateMachineType (i=213)
│ ├─ CurrentState
│ ├─ Prepare (Method)
│ ├─ Abort (Method)
│ └─ Resume (Method)
├─ Installation InstallationStateMachineType (i=249)
│ ├─ CurrentState
│ ├─ InstallSoftwarePackage (InstallSoftwarePackageMethodType i=389)
│ ├─ InstallFiles (InstallFilesMethodType)
│ ├─ Uninstall (Method)
│ └─ Resume (Method)
├─ PowerCycle PowerCycleStateMachineType (i=285)
└─ Confirmation ConfirmationStateMachineType (i=307)
└─ Confirm (Method)
The Loading subtype is configurable: UsePackageLoading() (default,
file transfer + CloseAndCommit), UseDirectLoading(),
UseCachedLoading(). The full structure is added to the
AsyncCustomNodeManager's PredefinedNodes via
AddPredefinedNodeAsync, so direct NodeId lookup, browse, subscription
wiring, and method calls all work out of the box.
Server side — fluent surface
using Opc.Ua.Di.Server.Builders;
using Opc.Ua.Di.Server.SoftwareUpdate;
// 1) Register a server-wide package store (commonly via
// .NET Microsoft.Extensions.DependencyInjection).
services.AddSingleton<ISoftwarePackageStore, MemoryPackageStore>();
// 2) Inside ConfigureDevicesFor<DiNodeManager>:
device.WithSoftwareUpdate(
ctx.GetRequiredService<ISoftwarePackageStore>(),
su => su.UsePackageLoading());
WithSoftwareUpdate auto-registers a per-device
MemorySoftwareFolder keyed by the device's NodeId. Override with
.WithSoftwareFolder(folder) if you need a custom folder
implementation (e.g. FileSystemSoftwareFolder for persistence
between server restarts).
Method-handler hooks
The library ships "succeed immediately" defaults for every method so the sample is fully exercisable out of the box. Override individual hooks to drive the real firmware-flashing logic:
device.WithSoftwareUpdate(packageStore, su => su
.OnPrepare(async (ctx, ct) =>
{
// Validate the staged package, lock the device, etc.
await myDevice.StartPreparationAsync(ct);
})
.OnInstall(async (ctx, package, ct) =>
{
// Flash the firmware. Throw to fail the state machine.
await myDevice.FlashAsync(package, ct);
})
.OnConfirm(async (ctx, success, ct) =>
{
await myDevice.CommitOrRollbackAsync(success, ct);
})
.OnUninstall(async (ctx, ct) =>
{
await myDevice.UninstallAsync(ct);
}));
ISoftwareUpdateContext exposes the device NodeId, the server's
system context, the package store, and the per-device software
folder so handlers can persist version metadata without having to
re-resolve any of these.
Server-side state reporting
The four state machines (PrepareForUpdate, Installation,
PowerCycle, Confirmation) are pre-seeded to their initial state
(Idle / NotWaitingForConfirm / NotWaitingForPowerCycle) when
the SU facet attaches. Every successful method invocation walks the
FSM through the standard Part 16 transitions and publishes
CurrentState / LastTransition updates that subscribers observe in
real time. Failures route to the Error state (Installation) or back
to Idle (PrepareForUpdate), again with proper LastTransition
metadata. The Installation FSM also exposes a per-operation
PercentComplete byte that walks 0 → 100 on success.
To plug application instrumentation into the same lifecycle without having to subscribe through the address space, register the state-changed hooks:
device.WithSoftwareUpdate(packageStore, su => su
.OnInstall(async (ctx, package, ct) => await myDevice.FlashAsync(package, ct))
.OnInstallationStateChanged((ctx, change) =>
{
_logger.LogInformation(
"Install on device {Device}: phase={Phase} progress={Pct}% message={Msg}",
ctx.DeviceId, change.Phase, change.ProgressPercent, change.Message);
return default;
}));
Each hook fires twice per method call — Started before the
application callback runs, then Completed on success or Failed
(with the exception message) on failure. The hook is invoked from the
service call's async context; exceptions thrown by the hook are
logged and swallowed so instrumentation faults never abort the
underlying SU operation. Domain-keyed SoftwareUpdatePhase (rather
than raw Part 16 cause / transition ids) keeps application code
independent of dispatcher internals.
Note — the source-generated DI FSMs ship without Part 16
StateTable/TransitionTable/CauseMappingsoverrides, soFiniteStateMachineState.SetState(...)(and by extensionStateMachineBuilder.For(...).WithCause(...)) is a no-op against them today. The SU wiring works around this by writingCurrentState/LastTransitiondirectly via the internalSoftwareUpdateStateMachineDispatcherhelper. Once the generator emits the Part 16 tables (tracked as a separate generator enhancement) the dispatcher can collapse into a thinStateMachineBuilder.For(...)adapter without touching the public hook surface.
Storage abstractions
Two independent storage abstractions live under
Opc.Ua.Di.Server.SoftwareUpdate:
| Type | Role |
|---|---|
ISoftwarePackageStore | Server-wide repository: Add/Get/List/Delete by id + binary payload. Backs the Loading file-transfer pipeline. |
ISoftwareFolder | Per-device multi-version archive (Current / Previous / Future). Updated by the default Install handler. |
Default implementations:
| Implementation | Use case |
|---|---|
MemoryPackageStore | In-process tests + samples. |
FileSystemPackageStore | Disk-backed, composed over IFileSystemProvider. |
MemorySoftwareFolder | Default for WithSoftwareUpdate. |
FileSystemSoftwareFolder | Persistence across server restarts. |
File-transfer pipeline
When UsePackageLoading() is selected (the default), the SU facet
materialises a TemporaryFileTransferType instance under
SoftwareUpdate.Loading.FileTransfer. The internal
SoftwareUpdateFileTransferManager implements the OPC 10000-5 §11.4
two-tier protocol:
- Client calls
Loading.FileTransfer.GenerateFileForWrite(generateOptions). The server allocates a fresh handle, creates a transientFileTypechild (namedUploadFile_<handle>) backed by an in-memory buffer, and returns the file NodeId + handle. ThegenerateOptionsargument is treated as a vendor-defined hint; the default manager interprets a singleStringvalue as the suggested package id. - Client opens the returned FileState in
Write|EraseExistingmode (mode 6), streams chunks via the standardWritemethod, and closes it. - Client calls
Loading.FileTransfer.CloseAndCommit(fileHandle). The server reads the buffered payload, packages it into aSoftwarePackage(id from thegenerateOptionshint or a timestamped fallback), hands it toISoftwarePackageStore.AddAsync(metadata, stream), and removes the transient FileState from the address space.
Operational caps (defaults, configurable via constants on
SoftwareUpdateFileTransferManager):
| Cap | Value |
|---|---|
| Concurrent upload handles per FileTransfer | 8 |
| Max buffered upload size | 64 MiB |
| Supported open mode | Write | EraseExisting (6); other modes are rejected with BadNotSupported |
| Read on a transient upload file | rejected with BadNotSupported |
Handles are owned by the session that allocated them; cross-session
access is rejected with BadUserAccessDenied.
Note —
DirectLoadingTypeandCachedLoadingTypeinheritPackageLoadingTypeand therefore expose the same FileTransfer slot. The wiring is shared because the upload semantics are identical; the difference between Package / Direct / Cached lives in the application'sOnInstallhandler (which decides whether to deploy immediately, atomically swap, or stage as fallback).
Client side
The minimal v1 SoftwareUpdateClient reads the device's
SoftwareVersion property:
var client = new SoftwareUpdateClient(
session, softwareUpdateNodeId, telemetry);
string version = await client.ReadSoftwareVersionAsync();
Uploading a package
SoftwareUpdateClient.UploadPackageAsync drives the full
GenerateForWrite → Open → Write* → Close → CloseAndCommit flow,
streaming the payload in chunkSizeBytes-sized chunks (default 8 KiB):
var client = new SoftwareUpdateClient(session, suNodeId, telemetry);
// byte-array convenience overload
long bytesUploaded = await client.UploadPackageAsync(
payload: firmwareBytes,
suggestedPackageId: "acme-firmware-2.0.0",
ct: ct);
// streaming overload (does not seek the stream)
using FileStream fs = File.OpenRead("firmware.bin");
await client.UploadPackageAsync(
payload: fs,
suggestedPackageId: "acme-firmware-2.0.0",
chunkSizeBytes: 16 * 1024,
ct: ct);
After commit the package is queryable via the server's
ISoftwarePackageStore.GetAsync(suggestedPackageId). The recommended
deployment flow is: upload via UploadPackageAsync(...) → drive the
state machines via PrepareAsync / InstallSoftwarePackageAsync /
ConfirmAsync as described below.
Typed Part 16 state-machine surface
SoftwareUpdateClient exposes typed accessors for the four child
state machines (PrepareForUpdate, Installation, Confirmation,
PowerCycle), each composed over the source-generated
*StateMachineTypeClient proxies and the generic Part 16 extensions
(GetCurrentFiniteStateAsync, ObserveFiniteTransitionsAsync,
WaitForStateAsync). The proxies are resolved lazily on first use
via SoftwareUpdateTypeClient.GetXxxAsync(telemetry, ct) and cached
for the lifetime of the client so the browse-path-translate
round-trip happens at most once per state machine.
var client = new SoftwareUpdateClient(session, suNodeId, telemetry);
// Snapshot the current state of any of the four FSMs.
FiniteStateSnapshot? install = await client.GetInstallationStateAsync(ct);
FiniteStateSnapshot? prep = await client.GetPrepareForUpdateStateAsync(ct);
// Drive cause methods (resolved against the typed proxy — no manual
// TranslateBrowsePath / method-NodeId look-up required).
await client.PrepareAsync(ct);
await client.InstallSoftwarePackageAsync(
manufacturerUri: "urn:acme",
softwareRevision: "2.0.0",
patchIdentifiers: ArrayOf.Empty<string>(),
hash: default,
ct: ct);
await client.ConfirmAsync(ct);
// Stream transitions via a streaming subscription.
await foreach (FiniteStateSnapshot snap in client
.ObserveInstallationTransitionsAsync(streaming, options: null, ct))
{
_logger.LogInformation("Installation moved to {State}", snap.CurrentState);
}
When the server does not expose the optional child the Get*StateAsync
overloads return null; the cause-method wrappers
(PrepareAsync, InstallSoftwarePackageAsync, ConfirmAsync, etc.)
throw ServiceResultException(BadNotFound) so calling code never
needs to redo browse-path resolution. See
StateMachines.md for the generic state-machine
API the typed wrappers build on.
Walkthrough — Applications/PumpDeviceIntegrationServer
The pump server doubles as the software-update walkthrough: a second
declarative pump (Pump #2) created via ConfigureDevicesFor has the
SU facet attached on top of the standard DI identification properties.
services.AddSingleton<ISoftwarePackageStore, MemoryPackageStore>();
services
.AddOpcUa()
.AddServer(o =>
{
o.ApplicationName = "PumpDeviceIntegrationServer";
o.EndpointUrls.Add($"opc.tcp://localhost:{port}/PumpDeviceIntegrationServer");
})
.AddNodeManager<PumpNodeManagerFactory>()
.ConfigureDevicesFor<PumpNodeManager>(async ctx =>
{
var pump = await ctx.CreateDeviceAsync(
new QualifiedName("Pump #2", ctx.Manager.DiNamespaceIndex));
pump.WithIdentification(id =>
{
id.Manufacturer = new LocalizedText("Acme Pumps Inc.");
id.Model = new LocalizedText("PumpX-2000 (declarative)");
id.SerialNumber = "SN-DI-2";
id.SoftwareRevision = "2.5.3";
});
var packageStore = ctx.GetRequiredService<ISoftwarePackageStore>();
await SoftwarePackageSeeder.SeedAsync(packageStore);
pump.WithSoftwareUpdate(packageStore, su => su.UsePackageLoading());
});
await builder.Build().RunAsync();
A client connecting to the running server will discover the SU
subtree under Pump #2.SoftwareUpdate, can browse the state
machines, and invoke InstallSoftwarePackage,
PrepareForUpdate.Prepare, Confirmation.Confirm, etc. The
library-supplied default handlers record the installed version in the
per-device MemorySoftwareFolder, so a subsequent read of the
device's SoftwareVersion reflects the new version.
Implementation pointers
- Fluent surface:
Libraries/Opc.Ua.Di.Server/Builders/ISoftwareUpdateBuilder.csandDeviceBuilderSoftwareUpdateExtensions.cs. - Address-space wiring:
SoftwareUpdateFacetWiring.cs(internal). - State-machine dispatcher:
SoftwareUpdateStateMachineDispatcher.cs(internal — directCurrentState/LastTransitionwrites until the model source generator emits Part 16 tables for*StateMachineState). - File-transfer pipeline:
Libraries/Opc.Ua.Di.Server/Builders/SoftwareUpdateFileTransferManager.cs(internal —GenerateFileForWrite/CloseAndCommit+ per-handle transientFileTypematerialisation). - Storage abstractions:
Libraries/Opc.Ua.Di.Server/SoftwareUpdate/ISoftwarePackageStore.csandISoftwareFolder.cs. - Client helpers:
Libraries/Opc.Ua.Di.Client/SoftwareUpdateClient.cs(read-only discovery),Libraries/Opc.Ua.Di.Client/SoftwareUpdateClient.StateMachine.cs(typed Part 16 partial), andLibraries/Opc.Ua.Di.Client/SoftwareUpdateClient.Upload.cs(UploadPackageAsyncdriving the FileTransfer pipeline). - Tests:
Tests/Opc.Ua.Di.Tests/DeviceBuilderSoftwareUpdateTests.cs,DeviceBuilderSoftwareUpdateStateChangeTests.cs,SoftwareUpdateFileTransferTests.cs(server side),SoftwareUpdateClientStateMachineTests.cs,SoftwareUpdateClientUploadTests.cs,SoftwareUpdateClientUploadIntegrationTests.cs(client + bridge end-to-end againstDiInProcessSessionBridge),MemorySoftwareFolderTests.cs,PackageStoreTests.cs,SoftwareUpdateClientTests.cs. - Sample:
Applications/PumpDeviceIntegrationServer/Program.cs(thePump #2declarative-device block attaches the SU facet).
Spec references
- OPC 10000-100 §10.3 — Software Update (companion specification).
- OPC 10000-5 §11.4 —
TemporaryFileTransferTypeand theGenerateFileForRead/GenerateFileForWrite/CloseAndCommitmethod triplet.