mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
0de25de89a
This pull request updates the Device Integration documentation and the `PumpDeviceIntegrationServer` sample to introduce and demonstrate the new `ITopologyElementBuilder<TElement>` API for working with OPC UA Device Integration topology elements. The changes clarify the distinction between device and topology-element builders, update sample code to use the new builder, and improve the overall clarity and accuracy of both the documentation and the sample implementation. **API and Documentation Updates:** * Introduced `ITopologyElementBuilder<TElement>` as the unified builder for topology elements, exposing common operations such as functional groups and topology references, and updated all references and examples to use this builder where appropriate. [[1]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaR164-R173) [[2]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaR219-R228) [[3]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaL897-R926) [[4]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaL912-R943) [[5]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaL926-R958) * Clarified the distinction between device builders (`IDeviceBuilder<TDevice>`) and topology-element builders in both the documentation and sample code, and documented the available builder methods and their intended use. [[1]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaL33-R38) [[2]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaL141-R146) [[3]](diffhunk://#diff-93e580c17580d8cb3c5c4206fdf6b64c1deabc1e00e4f83d64583661a97f76eaR495-R500) **Sample Code Modernization:** * Refactored `samples/PumpDeviceIntegrationServer/Program.cs` to use the new `ITopologyElementBuilder<TElement>` API for creating and configuring the second pump, including setting identification properties and functional groups, and removed legacy code for device health and software update facets. [[1]](diffhunk://#diff-7e065631de4a55cb923ba4fa9a32fffcff804ce5ef3e09a70a2eead76425b18dL74-R98) [[2]](diffhunk://#diff-7e065631de4a55cb923ba4fa9a32fffcff804ce5ef3e09a70a2eead76425b18dL133-L139) [[3]](diffhunk://#diff-7e065631de4a55cb923ba4fa9a32fffcff804ce5ef3e09a70a2eead76425b18dL53-L57) [[4]](diffhunk://#diff-7e065631de4a55cb923ba4fa9a32fffcff804ce5ef3e09a70a2eead76425b18dL37-R37) * Cleaned up and modernized the sample project description and removed unused or outdated fields and comments. [[1]](diffhunk://#diff-e3976eed0bf7d7b96d6762d58e8101a0670b2ef60aa04609e8d5ff4be30f64beL8-R8) [[2]](diffhunk://#diff-70693dd3c39308e8e25d4e009281950f860a914afcf92b2e100a0710b5bb8db1L58-L76) [[3]](diffhunk://#diff-70693dd3c39308e8e25d4e009281950f860a914afcf92b2e100a0710b5bb8db1L110-R91) [[4]](diffhunk://#diff-70693dd3c39308e8e25d4e009281950f860a914afcf92b2e100a0710b5bb8db1L129-R110) **Software Update Example Improvements:** * Updated the software update walkthrough in the documentation to use the new builder APIs and improved the clarity of the example, including more accurate references to the simulated device and software update facet. [[1]](diffhunk://#diff-5342d39e79e59ca4fc77a9d6277377841c321d17476979f612c0487c53342238L300-R303) [[2]](diffhunk://#diff-5342d39e79e59ca4fc77a9d6277377841c321d17476979f612c0487c53342238L313-R337) [[3]](diffhunk://#diff-5342d39e79e59ca4fc77a9d6277377841c321d17476979f612c0487c53342238L378-L379) These changes make it easier to work with OPC UA Device Integration topology elements, provide clearer guidance in the documentation, and ensure the sample code reflects current best practices.# Description _Describe the changes here to communicate to the maintainers why they should accept this pull request. By default - this will become the Commit message after merging and thus define history._ ## Related Issues _Reference all GitHub issues this PR addresses. If there is no issue yet, open one and link it here._ _If this is a relatively large or complex change, a design must have been discussed in the related tracking issue and signed off (which becomes the Architectural Decision Record (ADR))._ - Fixes #github-issue-number, ... ## Checklist _Put an `x` in the boxes that apply. You can complete these step by step after opening the PR._ - [x] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [x] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [ ] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. --------- Co-authored-by: Marc Schier <marcschier@users.noreply.github.com> Co-authored-by: Marc Schier <marcschier@hotmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
382 lines
15 KiB
Markdown
382 lines
15 KiB
Markdown
# 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.
|
|
|
|
## 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
|
|
|
|
```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<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:
|
|
|
|
```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, 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:
|
|
|
|
```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 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` / `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. |
|
|
|
|
## 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_<handle>`) 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`.
|
|
|
|
> **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.
|
|
|
|
### 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<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`](StateMachines.md) for the generic state-machine
|
|
API the typed wrappers build on.
|
|
|
|
## Hosted-server walkthrough
|
|
|
|
Attach the software-update facet to a `ComponentType`-derived device
|
|
created through the Device Integration builder:
|
|
|
|
```csharp
|
|
services.AddSingleton<ISoftwarePackageStore, MemoryPackageStore>();
|
|
|
|
services
|
|
.AddOpcUa()
|
|
.AddServer(o =>
|
|
{
|
|
o.ApplicationName = "DeviceIntegrationServer";
|
|
o.EndpointUrls.Add($"opc.tcp://localhost:{port}/DeviceIntegrationServer");
|
|
})
|
|
.AddOpcUaDi()
|
|
.ConfigureDevicesFor<DiNodeManager>(async ctx =>
|
|
{
|
|
IDeviceBuilder<DeviceState> 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<ISoftwarePackageStore>();
|
|
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.
|