# Device Integration (DI) — Software Update Facet
> **Glossary:** "DI" throughout this document means OPC UA
> [Device Integration (OPC 10000-100)](https://reference.opcfoundation.org/specs/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.
## Contents
- [Address-space layout](#address-space-layout)
- [Server side — fluent surface](#server-side--fluent-surface)
- [Method-handler hooks](#method-handler-hooks)
- [Server-side state reporting](#server-side-state-reporting)
- [Storage abstractions](#storage-abstractions)
- [Package-store contract](#package-store-contract)
- [File-system layout and provider composition](#file-system-layout-and-provider-composition)
- [Seeding a package](#seeding-a-package)
- [File-transfer pipeline](#file-transfer-pipeline)
- [Client side](#client-side)
- [Uploading a package](#uploading-a-package)
- [Typed Part 16 state-machine surface](#typed-part-16-state-machine-surface)
- [Hosted-server walkthrough](#hosted-server-walkthrough)
- [Implementation pointers](#implementation-pointers)
- [Spec references](#spec-references)
## Address-space layout
`WithSoftwareUpdate(...)` materialises one
`SoftwareUpdateType` instance per device with the following children:
```mermaid
flowchart TD
device["Device
ComponentState / DeviceState"]
update["SoftwareUpdate
SoftwareUpdateType (OPC-DI i=1)"]
loading["Loading
Package / Direct / Cached LoadingType
Default: PackageLoadingType i=137"]
prepare["PrepareForUpdate
PrepareForUpdateStateMachineType (i=213)"]
install["Installation
InstallationStateMachineType (i=249)"]
cycle["PowerCycle
PowerCycleStateMachineType (i=285)"]
confirmation["Confirmation
ConfirmationStateMachineType (i=307)"]
device --> update
update --> loading
update --> prepare
prepare --> ps["CurrentState"]
prepare --> pm["Prepare (Method)"]
prepare --> pa["Abort (Method)"]
prepare --> pr["Resume (Method)"]
update --> install
install --> is["CurrentState"]
install --> package["InstallSoftwarePackage
(InstallSoftwarePackageMethodType i=389)"]
install --> files["InstallFiles
(InstallFilesMethodType)"]
install --> uninstall["Uninstall (Method)"]
install --> resume["Resume (Method)"]
update --> cycle
update --> confirmation --> confirm["Confirm (Method)"]
```
Choose the loading subtype with one of these methods:
- `UsePackageLoading()` (default), which uses file transfer and
`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
```csharp
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();
// 2) Inside ConfigureDevicesFor:
device.WithSoftwareUpdate(
ctx.GetRequiredService(),
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:
```csharp
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, server system context,
package store, and per-device software folder. Handlers can use these values
to persist version metadata without resolving them again.
### 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:
```csharp
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 method call raises two hook events. `Started` fires before the
application callback. On success, the method raises `Completed`; on failure,
it raises `Failed` with the exception message. The hook runs in 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` / `CauseMappings` overrides, so
> `FiniteStateMachineState.SetState(...)` (and by extension
> `StateMachineBuilder.For(...).WithCause(...)`) is a no-op against
> them today. The SU wiring works around this by writing
> `CurrentState` / `LastTransition` directly via the internal
> `SoftwareUpdateStateMachineDispatcher` helper. Once the generator
> emits the Part 16 tables (tracked as a separate generator
> enhancement) the dispatcher can collapse into a thin
> `StateMachineBuilder.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. |
### Package-store contract
`ISoftwarePackageStore` implementations must support concurrent calls.
The interface exposes the following asynchronous operations:
| Operation | Result |
| --- | --- |
| `ListAsync` | Enumerate package metadata. |
| `GetAsync` | Return the matching `SoftwarePackage`, or null if absent. |
| `ExistsAsync` | Report whether an id is present. |
| `OpenReadAsync` | Return a caller-owned payload stream; throw `FileNotFoundException` for an unknown id. |
| `AddAsync` | Copy the supplied stream and add or replace the package with that id. |
| `DeleteAsync` | Return whether a package was removed. |
`SoftwarePackage` carries `Id`, `Version`, `Vendor`, `Description`, `SizeBytes`,
`CreatedAt`, and an optional `Hash`. Both in-box stores compute `SizeBytes` and
`CreatedAt` during `AddAsync`; callers may supply zero and `default` for those
fields. They do not compute a content hash from an empty `Hash`.
See [`ISoftwarePackageStore`](../src/Opc.Ua.Di.Server/SoftwareUpdate/ISoftwarePackageStore.cs)
for the exact signatures.
### File-system layout and provider composition
`FileSystemPackageStore` stores each package beneath a provider-relative root:
```text
{root}/
{package-id}/
payload.bin
metadata.json
```
The JSON metadata uses the source-generated `SoftwarePackageJsonContext`, so
serialization does not require reflection. Package ids must be nonempty, must
not contain `/` or `\`, and must not be `.` or `..`; invalid ids throw
`ArgumentException`.
Reuse the server's configured `IFileSystemProvider`:
```csharp
ISoftwarePackageStore store = new FileSystemPackageStore(
provider: fileSystemProvider,
rootPath: "/SoftwarePackages");
```
The provider must permit writes for add/delete operations. A read-only provider
can still serve list, metadata, and payload-read operations. The same provider can
also be mounted through `FileSystemNodeManager`; use the store API rather than
editing package files directly.
`FileSystemSoftwareFolder` also confines every revision, marker-derived path and
provider-supplied entry to its canonical per-device root. Revision identifiers
must be single nonempty path components; traversal, dot-only and normalized
dot-segment aliases are rejected before file operations. Ordinary dotted
revisions such as `1.2.3` remain supported. Malformed markers cannot select or
delete a parent folder's payload or metadata.
### Seeding a package
Resolve `ISoftwarePackageStore` from the host, then copy the payload into it.
The caller owns and disposes the source stream:
```csharp
ISoftwarePackageStore store = serviceProvider.GetRequiredService();
using FileStream payload = File.OpenRead("firmware.bin");
SoftwarePackage package = await store.AddAsync(
new SoftwarePackage(
Id: "firmware-1.0.0",
Version: "1.0.0",
Vendor: "Acme",
Description: "Device firmware",
SizeBytes: 0,
CreatedAt: default,
Hash: string.Empty),
payload,
ct);
```
## 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:
1. Client calls `Loading.FileTransfer.GenerateFileForWrite(generateOptions)`.
The server allocates a fresh handle, creates a transient
`FileType` child (named `UploadFile_`) backed by an
in-memory buffer, and returns the file NodeId + handle. The
`generateOptions` argument is treated as a vendor-defined hint;
the default manager interprets a single `String` value as the
suggested package id.
2. Client opens the returned FileState in `Write|EraseExisting` mode
(mode 6), streams chunks via the standard `Write` method, and
closes it.
3. Client calls `Loading.FileTransfer.CloseAndCommit(fileHandle)`.
The server reads the buffered payload, packages it into a
`SoftwarePackage` (id from the `generateOptions` hint or a
timestamped fallback), hands it to
`ISoftwarePackageStore.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`. Remote allocation, Open and every
handle-consuming method require a real, non-null session identifier; authenticated
sessionless requests receive `BadSessionIdInvalid` before allocation, staging,
cursor changes or storage access. Handle numbers and `NodeId.Null` are not
ownership capabilities. A transient file's methods also reject a handle belonging
to another transient file, even when the same session owns both.
Use an ordinary session for remote software uploads. Explicit trusted in-process
calls using a system context without an operation context remain supported;
their handles cannot be borrowed by remote callers. This does not disable
sessionless invocation for unrelated stateless services.
Committed package metadata contains the SHA-256 digest of the uploaded bytes.
The `InstallSoftwarePackage` Hash argument is forwarded as hexadecimal
`SoftwarePackage.Hash` to the application's install callback. A digest records
content identity, not publisher authenticity: production applications need
their own signature, publisher, device and deployment authorization checks.
> **Note** — `DirectLoadingType` and `CachedLoadingType` inherit
> `PackageLoadingType` and 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's `OnInstall` handler (which decides whether to
> deploy immediately, atomically swap, or stage as fallback).
## Client side
The minimal v1 `SoftwareUpdateClient` reads the device's
`SoftwareVersion` property:
```csharp
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):
```csharp
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.
Use `UploadPackageWithResultAsync` when the server can return asynchronous
completion evidence:
```csharp
using FileStream payload = File.OpenRead(packagePath);
SoftwareUpdateUploadResult result = await client.UploadPackageWithResultAsync(
payload, suggestedPackageId: packageId, ct: ct);
if (!result.CompletionStateMachine.IsNull)
{
// Observe this server-provided state machine before treating processing as complete.
// UploadPackageWithResultAsync does not wait for it or install any software.
}
```
The result includes both the byte count and `CompletionStateMachine`.
The `UploadPackageAsync` overloads return only the byte count.
Malformed/ambiguous translations, bad service headers and malformed commit
outputs are reported instead of becoming successful uploads. Cancellation stops
transmission and does not send `CloseAndCommit`; cleanup of an opened handle uses
an independent five-second token. A rejected or unavailable cleanup call is
logged, not proof that the server rolled back staged data.
### 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.
```csharp
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(),
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`](StateMachines.md) for the generic state-machine
API the typed wrappers build on.
## Hosted-server walkthrough
For a repository-only in-memory walkthrough, run
`PumpDeviceIntegrationServer` with `--host localhost --software-update-demo true`.
This explicitly adds a separate `SoftwareUpdateDemo` device; it does not update
the simulated pumps or execute package contents. Its install callback requires a
matching staged SHA-256, rehashes the stored bytes and copies the package into an
in-memory software-version folder. See the
[pump sample guide](../samples/Di/PumpDeviceIntegrationServer/README.md#software-update-simulator).
Attach the software-update facet to a `ComponentType`-derived device
created through the Device Integration builder:
```csharp
services.AddSingleton();
services
.AddOpcUa()
.AddServer(o =>
{
o.ApplicationName = "DeviceIntegrationServer";
o.EndpointUrls.Add($"opc.tcp://localhost:{port}/DeviceIntegrationServer");
})
.AddOpcUaDi()
.ConfigureDevicesFor(async ctx =>
{
IDeviceBuilder device = await ctx.CreateDeviceAsync(
new QualifiedName("Controller #1", ctx.Manager.DiNamespaceIndex));
device.WithIdentification(id =>
{
id.Manufacturer = new LocalizedText("Acme Controls");
id.Model = new LocalizedText("Controller 2000");
id.SerialNumber = "SN-DI-1";
id.SoftwareRevision = "2.5.3";
});
var packageStore = ctx.GetRequiredService();
device.WithSoftwareUpdate(packageStore, su => su.UsePackageLoading());
});
await builder.Build().RunAsync();
```
A client connecting to the running server will discover the SU
subtree under `Controller #1.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: `src/Opc.Ua.Di.Server/Builders/ISoftwareUpdateBuilder.cs`
and `DeviceBuilderSoftwareUpdateExtensions.cs`.
- Address-space wiring: `SoftwareUpdateFacetWiring.cs` (internal).
- State-machine dispatcher: `SoftwareUpdateStateMachineDispatcher.cs`
(internal — direct `CurrentState` / `LastTransition` writes until
the model source generator emits Part 16 tables for `*StateMachineState`).
- File-transfer pipeline:
`src/Opc.Ua.Di.Server/Builders/SoftwareUpdateFileTransferManager.cs`
(internal — `GenerateFileForWrite` / `CloseAndCommit` + per-handle
transient `FileType` materialisation).
- Storage abstractions:
`src/Opc.Ua.Di.Server/SoftwareUpdate/ISoftwarePackageStore.cs` and
`ISoftwareFolder.cs`.
- Client helpers: `src/Opc.Ua.Di.Client/SoftwareUpdateClient.cs`
(read-only discovery),
`src/Opc.Ua.Di.Client/SoftwareUpdateClient.StateMachine.cs`
(typed Part 16 partial), and
`src/Opc.Ua.Di.Client/SoftwareUpdateClient.Upload.cs`
(`UploadPackageAsync` driving 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 against `DiInProcessSessionBridge`),
`MemorySoftwareFolderTests.cs`, `PackageStoreTests.cs`,
`SoftwareUpdateClientTests.cs`.
## Spec references
- OPC 10000-100 §10.3 — Software Update (companion specification).
- OPC 10000-5 §11.4 — `TemporaryFileTransferType` and the
`GenerateFileForRead` / `GenerateFileForWrite` / `CloseAndCommit`
method triplet.