diff --git a/UA.slnx b/UA.slnx
index f83cecef70..56469001f5 100644
--- a/UA.slnx
+++ b/UA.slnx
@@ -85,6 +85,11 @@
+
+
+
+
+
@@ -250,6 +255,7 @@
+
diff --git a/docs/RuntimeNodeSets.md b/docs/RuntimeNodeSets.md
index c72a0fafe7..a8e7d49b1e 100644
--- a/docs/RuntimeNodeSets.md
+++ b/docs/RuntimeNodeSets.md
@@ -16,7 +16,7 @@ Use the [source-generated path](SourceGeneratedNodeManagers.md) when you want co
`AddRuntimeNodeSet` on `IOpcUaServerBuilder` remains the startup path: its factory is created before the server starts and its NodeSet is imported during `CreateAddressSpaceAsync`.
-Running servers also expose `INodeManagerLifecycle`. Resolve it from dependency injection in a hosted server, or use `StandardServer.NodeManagerLifecycle` when constructing the server directly. The lifecycle provider can add, reload, and remove runtime NodeSets without restarting the server.
+Running servers also expose `INodeManagerLifecycle`. Resolve it from dependency injection in a hosted server, or use `StandardServer.NodeManagerLifecycle` when constructing the server directly. The lifecycle provider can add, reload, shadow-reload, and remove runtime NodeSets without restarting the server.
```csharp
public sealed class ModelLoader(INodeManagerLifecycle lifecycle)
@@ -55,17 +55,46 @@ Each add returns an immutable `NodeManagerRegistration`. Reload returns the next
Reload and removal fail when the current NodeManager owns active monitored items. Delete those monitored items first, then retry. This fail-closed rule prevents a live subscription from retaining a stale manager handle.
+### Shadow reload
+
+`ShadowReloadRuntimeNodeSetAsync` (backed by `INodeManagerLifecycle.ShadowReloadAsync`) replaces a live registration the same way `ReloadRuntimeNodeSetAsync` does, but without the active-monitored-item guard:
+
+```csharp
+public async ValueTask ShadowReloadAsync(CancellationToken ct)
+{
+ m_registration = await lifecycle.ShadowReloadRuntimeNodeSetAsync(
+ m_registration!,
+ new RuntimeNodeSetOptions
+ {
+ Sources = [RuntimeNodeSetSource.FromFile("Models/MyMachine.NodeSet2.xml")]
+ },
+ ct);
+}
+```
+
+The replacement generation is prepared and published through the same transactional prepare/publish/commit/rollback path as `ReloadAsync`, so a failure during preparation, publication, or the routing switch leaves the current generation fully active and cleans up the replacement, exactly as a normal reload does. Once committed, every new service request is atomically routed to the replacement generation, including for namespaces the current and replacement generations share.
+
+The current generation is not torn down immediately. It is moved to the same retired-generation bookkeeping used for an ordinary reload, but its existing monitored items and any request or continuation point that already captured it keep being served by it, unaffected by the routing switch. The retired generation is disposed automatically, without deleting any client subscription, once its monitored items and in-flight state drain; a later lifecycle operation (or shutdown) opportunistically retries that cleanup until it succeeds. `ShadowReloadAsync` returns the replacement `NodeManagerRegistration` immediately and invalidates the current handle for further lifecycle mutations, the same as `ReloadAsync`.
+
+Use `ShadowReloadAsync` when a model update must take effect for new requests without waiting for existing subscriptions to unsubscribe first; use the fail-closed `ReloadAsync` when a stale generation must never remain reachable, even briefly, for already-open monitored items.
+
+### Immediate reload
+
+`ImmediateReloadRuntimeNodeSetAsync` (backed by `INodeManagerLifecycle.ImmediateReloadAsync`) performs the same atomic replacement but does not retain the previous generation until monitored items drain. After requests that already captured the old routing generation finish, every affected data-change monitored item is made publishable with `BadNodeIdUnknown`, event monitored items stop producing events, continuation points are invalidated, and the old NodeManager is disposed. The subscription and monitored-item records remain available so clients can receive the status and delete or recreate the affected items.
+
+Use immediate reload only when continuity through the previous generation is not required. Durable monitored items are not eligible for immediate retirement because their terminal state would have to survive restart; choose shadow reload for any generation that owns them.
+
Treat `INodeManagerLifecycle` as a host control-plane API. Do not invoke reload or removal from inside an OPC UA service or Method callback: teardown waits for requests that already captured the retired routing generation to complete before disposing it.
The built-in runtime NodeSet manager implements `INodeManagerReloadParticipant`, which transfers inbound cross-manager references to retained NodeIds and removes counterparts for dropped nodes. A custom NodeManager can be added and removed through the lifecycle provider, but must implement this participant contract before it can be reloaded safely.
-Reload and removal invalidate saved Browse continuation points owned by the retired manager. A later `BrowseNext` with one of those tokens returns `BadContinuationPointInvalid` instead of invoking a disposed generation.
+Reload and removal invalidate saved Browse continuation points owned by the retired manager. A later `BrowseNext` with one of those tokens returns `BadContinuationPointInvalid` instead of invoking a disposed generation. A shadow reload defers this invalidation until the retired generation's monitored items have drained, so continuation points that already captured it keep working until then. Immediate reload invalidates them as soon as in-flight requests complete.
Namespace indexes are append-only for the lifetime of a running server. Removing a model removes its nodes and routing but leaves its namespace URI in `NamespaceArray`; a later reload or add reuses the same index. When a live add appends a URI, the server updates `NamespaceArray` and `UrisVersion`.
Runtime DataType registrations are also additive. Reload accepts an existing DataType only when its definition is structurally compatible, rejects incompatible changes, and retains removed stand-in encodeables so existing sessions and in-flight values remain decodable.
-Every committed lifecycle transaction emits one compressed model-change notification. Reload also emits a semantic-change notification when values of properties marked with the `SemanticChange` access-level bit changed.
+Every committed lifecycle transaction, including shadow and immediate reload, emits one compressed model-change notification. Reload also emits a semantic-change notification when values of properties marked with the `SemanticChange` access-level bit changed.
## Quick-start examples
diff --git a/docs/WoTConnectivity.md b/docs/WoTConnectivity.md
index 6839c846c0..3b44f4bf25 100644
--- a/docs/WoTConnectivity.md
+++ b/docs/WoTConnectivity.md
@@ -6,7 +6,7 @@ class libraries plus an integration test project:
| Project | Purpose |
|----------------------------------|---------------------------------------------------------------|
-| `Opc.Ua.WotCon` | Source-generated information model (NodeStates, NodeIds, generated ObjectType client proxies) compiled from the official `WotConnection.xml` design + `WotConnection.csv` |
+| `Opc.Ua.WotCon` | Source-generated information model (NodeStates, NodeIds, generated ObjectType client proxies) generated once from the combined **WoT Connectivity 1.1** NodeSet2 (incorporating the OPC 10100-1 v1.02 model plus additive registry nodes in one namespace) and the draft **xRegistry** base NodeSet2 (see §11) |
| `Opc.Ua.WotCon.Server` | Server-side node manager (`WotConnectivityNodeManager` → `AsyncCustomNodeManager`) and the extensible provider model |
| `Opc.Ua.WotCon.Client` | Client wrappers + extension methods that compose the generated proxies without inheritance |
| `Opc.Ua.WotCon.Tests` | NUnit tests covering the TD parser, mappers, simulated provider, discovery facade |
@@ -382,11 +382,12 @@ services.AddOpcUa()
});
```
-To loosen the policy (for example a closed lab deployment where the
-client cannot present a non-anonymous identity), set
-`AllowAnonymous = true` and grant the anonymous identity the chosen
-role via your role-mapping layer; do not weaken `MinimumSecurityMode`
-in production.
+To loosen identity policy in a closed lab, set `AllowAnonymous = true`
+and grant the anonymous identity the chosen role via your role-mapping
+layer. A conformant registry mutation surface still requires
+`MinimumSecurityMode = MessageSecurityMode.SignAndEncrypt`; lowering it
+is suitable only for isolated test harnesses. Read-only registry access
+may be exposed over `MessageSecurityMode.None` by deployment policy.
---
@@ -413,3 +414,301 @@ in production.
* OPC 10100-1, *WoT Connectivity for OPC UA*: https://reference.opcfoundation.org/specs/OPC-10100-1/full
* W3C Web of Things Thing Description 1.1: https://www.w3.org/TR/wot-thing-description11/
* W3C WoT Binding Templates: https://w3c.github.io/wot-binding-templates/
+
+---
+
+## 11. WoT Connectivity 1.1 registry and materialization (preview)
+
+The `Opc.Ua.WotCon` assembly is source-generated once from the combined
+**WoT Connectivity 1.1** NodeSet2, which incorporates the published OPC
+10100-1 v1.02 model (NodeIds `1..172`, marked deprecated) plus the additive
+registry nodes (`64000+`) in one namespace, and from the abstract
+**xRegistry** base model the registry types build on:
+
+| Model | Namespace | Emitted C# namespace |
+|-------|-----------|----------------------|
+| xRegistry (abstract registry base) | `http://opcfoundation.org/UA/xRegistry/` | `Opc.Ua.XRegistry` |
+| WoT Connectivity 1.1 (combined) | `http://opcfoundation.org/UA/WoT-Con/` | `Opc.Ua.WotCon` |
+
+Both NodeSet2 models are *pinned* from the OPC UA drafts authoring
+repository into `src/Opc.Ua.WotCon/Design` (as `*.NodeSet2.xml` +
+`*.NodeSet2.csv`) and added as `AdditionalFiles`. The legacy 1.02
+`WotConnection.xml` / `WotConnection.csv` sources are retained under
+`Design/` for reference only — they are incorporated into the combined
+NodeSet and are **not** source-generated a second time, so the preserved
+1.02 constants and the additive registry constants coexist in one
+`Opc.Ua.WotCon` namespace under their exact NodeIds. Run
+`pwsh src/Opc.Ua.WotCon/Design/Sync-WotConModels.ps1 -Check` to verify
+the pinned copies still match the draft repository (use `-Update` to
+refresh them).
+
+### 11.1 Architecture
+
+The 1.1 runtime separates a **stable registry** from **ephemeral
+projections**:
+
+* `WotRegistryNodeManager` (stable) exposes the well-known `WoTRegistry`
+ object, its Thing Description / Thing Model groups, the `Refresh`
+ Method, registry settings and the registry event types. It never re-creates
+ itself. Every service group and document resource is additionally
+ materialized as a browseable `ThingDescriptionGroupType` /
+ `ThingModelGroupType` and `ThingDescriptionFileType` /
+ `ThingModelFileType` node beneath `WoTRegistry`, kept in sync with the
+ registry snapshot (see §11.7). It never re-creates itself.
+* Registry documents are projected into the AddressSpace as **separate
+ runtime NodeManagers** through the public `INodeManagerLifecycle`
+ (`AddRuntimeNodeSetAsync` for first activation,
+ `ShadowReloadRuntimeNodeSetAsync` or
+ `ImmediateReloadRuntimeNodeSetAsync` for updates). Graceful retirement
+ keeps the previous generation serving existing monitored items until
+ they drain. Immediate retirement reports `BadNodeIdUnknown` for affected
+ monitored items and disposes the previous generation without waiting
+ for drain.
+
+Register it on an OPC UA server host:
+
+```csharp
+builder
+ .AddServer(server => { /* ... */ })
+ .AddWotRegistryServer(options =>
+ {
+ options.StorageFolder = Path.Combine(AppContext.BaseDirectory, "wot-registry");
+ options.AutoRefresh = true; // re-project after every content mutation
+ options.StrictBindings = false; // materialize degraded nodes for unsupported forms
+ options.RetirementPolicy = WotProjectionRetirementPolicy.Graceful;
+ });
+```
+
+### 11.2 Registry service and persistence
+
+`IWotRegistryService` owns an immutable `WotRegistrySnapshot`. Every
+mutation produces a new snapshot with a strictly greater `Generation`
+(epoch); readers hold a snapshot and never observe a partial change. A
+resource carries its versions (raw source bytes + SHA-256 content
+digest), desired/active version pointers, `WoTLoadStateEnum`,
+`WoTValidationOutcomeDataType` and diagnostics.
+
+Two persistence back-ends are provided:
+
+* `InMemoryWotRegistryStore` — volatile; the registry starts empty.
+* `FileWotRegistryStore` — durable; metadata is written with a **bounded
+ atomic replace** (write-to-temp then `File.Replace`), one blob per
+ version, content-addressed directories. Invalid documents are stored
+ with their failure state so a restart restores exactly the last
+ observed contents.
+
+Resource bounds (`WotRegistryPersistenceBounds`) cap document size,
+versions per resource, resources per group, and group count.
+
+### 11.3 Materialization coordinator
+
+`WotMaterializationCoordinator.RefreshAsync` drives projection:
+
+1. Parses/validates each registry document with `Opc.Ua.Wot`.
+2. Builds the TD/TM dependency graph from `links` (`rel = tm:extends /
+ type / tm:submodel`), a top-level `tm:extends`, and `tm:ref` pointers,
+ resolving references against the registry by Thing id / xid / resource
+ id. It never follows an arbitrary external URL; an unresolved absolute
+ URL remains a missing dependency unless a configured xRegistry
+ federation layer has registered it.
+3. Partitions the graph into **dependency closures** (weakly-connected
+ components) with Thing Models topologically ordered before the Thing
+ Descriptions that extend them; a shared model lands in a single
+ closure. Cycles and missing dependencies produce deterministic
+ diagnostics.
+4. Converts each closure to one or more NodeSet2 documents and projects
+ the closure as one runtime NodeManager (Add, or graceful/immediate
+ reload on update according to `RetirementPolicy`).
+
+Behaviours:
+
+* Independent closures commit independently; a failed or invalid closure
+ **retains its previous active generation**.
+* An **unchanged** closure (same content digest, options and binder
+ version) returns `WoTOutcomeEnum.Unchanged` and emits no model change.
+* `WotProjectionRetirementPolicy.Graceful` preserves existing monitored
+ items on the previous generation until drain.
+ `WotProjectionRetirementPolicy.Immediate` invalidates affected items
+ with `BadNodeIdUnknown` and disposes the previous generation. The proof
+ rejects immediate retirement when the old generation owns a durable
+ monitored item; configure `Graceful` for that closure.
+* `Refresh` returns a detailed `WoTRefreshSummaryDataType` plus a
+ per-resource `WoTResourceLoadResultDataType[]` and the new generation,
+ matching the generated Method signature.
+* The coordinator's events are re-emitted by the NodeManager as the
+ generated `WoTResourceEventType` / `WoTValidationFailureEventType` /
+ `WoTLoadFailureEventType` / `WoTBindingFailureEventType` /
+ `WoTRefreshCompletedEventType`.
+
+### 11.4 Binder integration seam
+
+`IWotBinderRegistry` is the runtime-neutral seam the coordinator uses
+during Prepare/Activate/Deactivate. Binding plans and capabilities are
+immutable. The default `NullWotBinderRegistry` registers no binders, so
+affordance forms either **fail a strict closure**
+(`StrictBindings = true`) or **materialize as degraded nodes**
+(`BadConfigurationError`) when non-strict. Concrete protocol planners and
+executors are added by registering an `IWotBinderRegistry`
+implementation; no network protocol is implemented in this phase.
+
+### 11.5 Legacy 1.02 compatibility
+
+The legacy `WotConnectivityNodeManager`, its generated 1.02
+namespace/NodeIds/method signatures and the client APIs are unchanged.
+When both features are hosted, legacy-created assets are additionally
+registered as Thing Description resources in a configured legacy group
+(`WotRegistryServerOptions.LegacyGroupId`) so they participate in registry
+materialization, without making the flat legacy asset list canonical for the registry.
+
+### 11.6 Known limitations (preview)
+
+* No concrete protocol binder ships in this phase (see §11.4). Affordance
+ forms therefore either fail a strict closure or materialize as degraded
+ nodes; no live protocol read/write/subscribe is performed yet.
+
+### 11.7 Browseable registry projection and management Methods
+
+The stable `WoTRegistryNodeManager` materializes the registry snapshot as a
+browseable object tree and wires the inherited xRegistry / registry Methods:
+
+* For every service group a `ThingDescriptionGroupType` or
+ `ThingModelGroupType` object is created beneath `WoTRegistry`, and for
+ every resource its `ThingDescriptionFileType` / `ThingModelFileType`
+ document node is created beneath the group. NodeIds are stable and
+ deterministic, derived from the registry Xid (for example
+ `WoTRegistry/groups/{groupId}/resources/{resourceId}`). The projection is
+ reconciled on every registry `Changed` event — including projection-only
+ callbacks, which never re-trigger materialization — and removes group and
+ resource nodes as they disappear from the snapshot.
+* Each node carries its xRegistry and registry metadata (ids/Xid/epoch/name/
+ description/timestamps/format/content type, desired/default/active
+ version, enabled/load state, validation outcome, content digest,
+ materialized-node count, the materialized `RootNodeId`, and selected
+ bindings). `HasNotifier` references chain `WoTRegistry` → group → resource
+ → `Server`, and resource lifecycle failure events are sourced at the
+ specific resource node (the registry object remains the source for the
+ refresh-completed summary event).
+* The xRegistry `CreateGroup` / `GetOrCreateGroup` (on `WoTRegistry`),
+ `CreateResource` / `GetOrCreateResource` / `Delete` (on a group) and the
+ document `Delete`, `Validate`, `SetEnabled` and `SetDefaultVersion` (on a
+ resource) Methods are wired to the registry service, enforcing
+ `ExpectedEpoch` optimistic concurrency and the management access policy.
+ Registry mutations require a `SignAndEncrypt` SecureChannel; deployments
+ may separately permit read-only registry access over `SecurityMode.None`.
+* The inherited FileType (`Open` / `Read` / `Write` / `Close` /
+ `GetPosition` / `SetPosition`) transfers the document body with
+ per-session handles, a single exclusive writer and bounds. Closing a
+ write handle commits the buffer as a new version; a document that fails
+ validation is still stored as an invalid version so the bytes are never
+ lost and the previous active projection is retained.
+* Every browseable registry/group/resource node also carries the inherited
+ optional `Labels` (`AttributesType`) container. Each label is persisted as
+ an ordinally-ordered key/value pair on the owning `WotRegistrySnapshot` /
+ `WotResourceGroup` / `WotResource` model and materializes as its own
+ `PropertyType` child with a deterministic NodeId (for example
+ `WoTRegistry/groups/{groupId}/labels/{key}`) and a safe, collision-checked
+ BrowseName. The container's `AddAttribute(Key, Value, ExpectedEpoch)` and
+ `RemoveAttribute(Key, ExpectedEpoch)` Methods enforce the management access
+ policy, optimistic-concurrency `ExpectedEpoch` (the group/resource's own
+ epoch; the registry singleton has no separate epoch so its Labels compare
+ against the snapshot `Generation`), the configured
+ `WotRegistryPersistenceBounds` (`MaxLabelsPerEntity`,
+ `MaxLabelKeyLength`, `MaxLabelValueLength`) and reject invalid/control/BIDI/
+ path characters or a key colliding with the container's own fixed
+ `AddAttribute`/`RemoveAttribute` member names, using the shared
+ `WotChildNameValidator`. `IWotRegistryService` exposes matching
+ `Add`/`RemoveRegistryLabelAsync`, `Add`/`RemoveGroupLabelAsync` and
+ `Add`/`RemoveResourceLabelAsync` service APIs; label mutations raise a
+ projection-only registry change so they update the browseable Labels
+ container without re-triggering materialization. Labels survive a registry
+ restart and file-store reload (persisted alongside their owning
+ group/resource, and — for the registry-level set — in a small
+ `registry.json`) and remain visible after every projection reconciliation.
+ Version-level labels are stored on the immutable `WotResourceVersion`
+ model for API completeness but are not materialized as a separate
+ AddressSpace node, since the xRegistry model does not define a
+ `VersionType.Labels` container (only Registry/Group/Resource expose one).
+
+### 11.8 Binding-vocabulary alignment (NodeSet2 ↔ WoT)
+
+`Opc.Ua.Wot.WotNodeSetConverter` maps a NodeSet2 model to a WoT Thing
+Model / Thing Description and back. The deterministic, versioned
+`uav:nodes` projection covers the complete UANodeSet schema and is emitted
+only when the semantic/readable mapping cannot reproduce all source facts;
+`uav:nodeSet` is emitted only for explicit byte archival or a demonstrated
+final fallback. Unmapped WoT JSON members are stored
+individually by RFC 6901 pointer in a `WoTJsonResidue` NodeSet Extension,
+not by copying the source document. The readable surface tracks the current
+[OPC UA WoT Binding](https://reference.opcfoundation.org/) revision:
+
+* **Semantic conversion is the default.** `WotNodeSetPreservationMode`
+ selects `WhenRequired` (default), `Always` (explicit byte archive), or
+ `Never` (conformance/completeness tests). The converter first reconstructs
+ the readable document and omits `uav:nodes` when it is equivalent; it then
+ validates the structured projection when fallback is required. Tests that
+ prove completeness use `Never` and assert that no opaque envelope exists.
+ `NodeSetRoundtripReport.NativeProjectionPreserved` and
+ `UsedPreservationEnvelope` distinguish the two paths.
+
+* **Unknown members survive as residue, not an envelope.** During
+ TD/TM-to-NodeSet synthesis, only unrecognized or unmapped JSON values are
+ stored in the root `Extensions` collection as digest-protected
+ `WoTJsonResidue/Member` entries. Reverse conversion regenerates mapped
+ facts from OPC UA and applies the pointer-addressed values. A collision
+ with a regenerated model fact is reported as
+ `WotDiagnosticCode.ResidueConflict`.
+
+* **Event affordances carry `uav:eventType`.** An OPC UA EventType (a
+ `BaseEventType` subtype) projects to an event affordance annotated
+ `@type: uav:eventType` alongside `uav:isEvent: true`; a NodeSet whose
+ root is an EventType is annotated the same way. The two forms are the
+ `@type` annotation and the boolean anchor of the same fact, so a
+ document that pairs `@type: uav:eventType` with `uav:isEvent: false`
+ is rejected (`WotDiagnosticCode.EventAnnotationConflict`). Reverse
+ conversion recreates a `BaseEventType` subtype from either form.
+
+* **Identity terms are portable ExpandedNodeIds.** Every persisted
+ identity term — `uav:id`, each `uav:hasComponent` / `uav:componentOf`
+ entry, `uav:mapToNodeId` / `uav:mapToType`, a NodeId-valued
+ `uav:refId`, and a generated `?id=` href — is emitted as an
+ OPC 10000-6 `nsu=;...` ExpandedNodeId, resolved through
+ the source NodeSet's `NamespaceUris` table so the value survives a
+ namespace-table reordering; namespace 0 keeps its canonical `i=` form
+ and the session-local `ns=` form is never emitted. On input the
+ converter diagnoses an `ns=` in any of these terms
+ (`WotDiagnosticCode.NonPortableIdentity`). The `uav:nodeSet` envelope
+ and NodeSet-local fields inside `uav:nodes` keep their own namespace
+ tables and are excluded from this readable-identity rule.
+
+* **BrowseNames are portable QualifiedNames.** Generated readable
+ `uav:browseName` values use OPC 10000-6 `nsu=;` for
+ non-base namespaces and the bare Name for namespace 0. Numeric
+ `namespaceIndex:name` is retained only inside `uav:nodes`, which carries
+ its own `namespaceUris` table.
+
+* **Model concepts carry NamespaceUri-qualified names.** Generated
+ contexts bind `ua` to the base OPC UA namespace and deterministic
+ `ns1`, `ns2`, … prefixes to companion NamespaceUris. A typed link emits
+ the ReferenceType model name directly in `rel` (for example
+ `ua:HasOrderedComponent`) beside its definitive `uav:refId`
+ ExpandedNodeId. Authored
+ `uav:mapToTypeName` / `uav:congruentTypeName` hints are validated and
+ preserved beside their definitive identifiers. Compact model names are
+ never used for arbitrary instance targets.
+
+* **`observable` advertises binding support.** A generated
+ `observable: true` / `observeproperty` form states that the TD exposes
+ observation through this binding. It is not a claim that other OPC UA
+ Variables are technically unmonitorable; any Variable can be a
+ MonitoredItem when the Server grants access.
+
+* **HasComponent subtypes are pinned by a typed link.**
+ `uav:hasComponent` / `uav:componentOf` expose parent-child ownership
+ for discovery across `HasComponent` and its subtypes. When the source
+ ReferenceType is a subtype (for example `HasOrderedComponent`, `i=49`),
+ the converter additionally emits a link whose `rel` is
+ `ua:HasOrderedComponent`, whose `uav:refId` is `i=49`, and
+ whose `uav:refName` names the reference.
+ Reverse conversion resolves the name, verifies the identifier when both
+ are present, recreates the exact subtype, and otherwise falls back to
+ plain `HasComponent`.
diff --git a/docs/WoTProtocolBindings.md b/docs/WoTProtocolBindings.md
new file mode 100644
index 0000000000..b1187dc068
--- /dev/null
+++ b/docs/WoTProtocolBindings.md
@@ -0,0 +1,224 @@
+# WoT Connectivity Protocol Bindings
+
+The WoT Connectivity 1.1 runtime materializes Thing Descriptions and Thing Models
+into the OPC UA AddressSpace. Each interaction-affordance **form** in a document
+describes how to reach a value over a concrete protocol (HTTP, MQTT, Modbus,
+OPC UA, …). The **protocol binder** subsystem turns those forms into validated,
+immutable **binding plans** and, when an executor is present, drives the live
+transport operations.
+
+The subsystem is deliberately layered so the core model and server assemblies
+carry **no transport dependencies**:
+
+| Assembly | Contents | Dependencies |
+| --- | --- | --- |
+| `Opc.Ua.WotCon.Binding` | Stable interfaces, plan model, codecs, the eight planner/validator binders, the sample binder | model only (dependency-light, all TFMs) |
+| `Opc.Ua.WotCon.Binding.Http` | HTTP executor | `HttpClient` (net8.0+) |
+| `Opc.Ua.WotCon.Binding.Mqtt` | MQTT executor | MQTTnet (net8.0+) |
+| `Opc.Ua.WotCon.Binding.Modbus` | Modbus TCP client + executor | sockets only (net8.0+) |
+| `Opc.Ua.WotCon.Binding.OpcUa` | OPC UA executor (OPC UA-to-OPC UA) | `Opc.Ua.Client` (net8.0+) |
+| `Opc.Ua.WotCon.Server` | Materialization coordinator integration | references `Opc.Ua.WotCon.Binding` only |
+
+> The concrete executors target **net8.0/net9.0/net10.0**. The dependency-light
+> planner assembly targets the full `net472;net48;netstandard2.1;net8.0;net9.0;net10.0`
+> matrix so unsupported runtime protocols can still validate and compile plans on
+> every framework.
+
+## Stable public interfaces
+
+All contracts live in the `Opc.Ua.WotCon.Binding` namespace.
+
+* **Identification, version and capability**
+ * `WotBindingIdentity` — a binder's stable `Id` + `Version` (`id@version` key).
+ Multiple versions of a binding coexist.
+ * `WotBindingSource` / `WotBindingMaturity` — the version-pinned specification a
+ binder implements (URL, version/date, commit, standards maturity).
+ * `WotBindingCapability` — supported operations, content types, executable flag;
+ projects onto the generated `WoTBindingCapabilityDataType`.
+ * `IWotBindingIdentification` — deterministic selection. A binder returns a
+ `WotBindingMatch` (kind + priority) so selection uses pinned rules
+ (explicit pin > vocabulary > subprotocol > scheme), **not the URI scheme
+ alone**.
+* **Form validation and compilation**
+ * `WotFormExtractor` / `WotAffordanceForm` — reflection-free extraction of forms
+ (with resolved `op` defaults, security scheme references and JSON Pointers).
+ * `IWotBindingPlanner` — validates a form and compiles it into a
+ `WotBindingCompilation` of immutable `WotCompiledForm` entries carrying
+ `WotEndpointDescriptor` / `WotAddressingDescriptor` / `WotOperationDescriptor`
+ / `WotPayloadDescriptor` metadata.
+* **Payload codec selection**
+ * `IWotPayloadCodec` / `IWotCodecRegistry` — reflection-free JSON, text and
+ octet-stream codecs; protocol executors may register more.
+* **Credential / trust reference lookup (no secrets in TD / registry nodes)**
+ * `WotSecurityDefinition` / `WotCredentialReference` — secret-free scheme
+ references parsed from `securityDefinitions`.
+ * `IWotCredentialProvider` — resolves a reference into short-lived
+ `WotCredential` material at runtime, out-of-band. No secret ever appears in a
+ Thing Description or on a registry node.
+* **Lifecycle and operations**
+ * `IWotBindingExecutor` — `ActivateAsync` opens a per-form `IWotBindingChannel`.
+ * `IWotBindingChannel` — `ReadAsync` / `WriteAsync` / `InvokeAsync` /
+ `ObserveAsync` / `SubscribeEventAsync`, returning `WotReadResult` /
+ `WotWriteResult` / `WotInvokeResult` with mapped `StatusCode`s.
+* **Registry and structured diagnostics**
+ * `IWotBinderRegistry` / `WotProtocolBinderRegistry` — the Prepare / Activate /
+ Deactivate seam the coordinator uses.
+ * `WotBindingDiagnostic` — severity + stable code + **RFC 6901 JSON Pointer**.
+
+## Protocol coverage
+
+Eight planner/validator binders ship in `Opc.Ua.WotCon.Binding`
+(`WotBuiltInBinders.CreateAll()`). Each pins its exact source in
+`Planners/WotBindingSources.cs`.
+
+| Binding | Id | Pinned source | Maturity | Executable |
+| --- | --- | --- | --- | --- |
+| HTTP | `w3c.http` | W3C TD 1.1 (normative HTTP mapping) | REC | yes (`.Http`) |
+| CoAP | `w3c.coap` | W3C Binding Templates CoAP | Editor's Draft | planner only |
+| MQTT | `w3c.mqtt` | W3C Binding Templates MQTT | Editor's Draft | yes (`.Mqtt`) |
+| Modbus TCP | `w3c.modbus` | W3C Binding Templates Modbus | Editor's Draft | yes (`.Modbus`) |
+| BACnet | `w3c.bacnet` | W3C Binding Templates BACnet | Editor's Draft | planner only |
+| PROFINET | `w3c.profinet` | WoT PROFINET contribution | Unofficial Draft | planner only |
+| LoRaWAN | `w3c.lorawan` | WoT LoRaWAN contribution | Unofficial Draft | planner only |
+| OPC UA | `opc.opcua` | OPC 10101 (OPC UA WoT Connectivity) | OPC specification | yes (`.OpcUa`) |
+
+Notes:
+
+* The **W3C Binding Templates registry is a pilot and currently empty**; no
+ binder ever reports `RegistryCurrent`. Drafts expose their Editor's Draft
+ maturity; OPC UA exposes the OPC specification maturity.
+* BACnet, PROFINET, LoRaWAN and CoAP perform **schema / document-level planning
+ only** and are reported as **non-executable** — the runtime materializes their
+ nodes but marks the closure degraded so callers know they cannot be driven yet.
+* Each planner validates the href scheme and the currently-defined vocabulary
+ terms of its pinned document, checks `op` compatibility, `contentType` and
+ required fields, produces immutable endpoint/addressing/operation/payload
+ metadata and returns precise errors/warnings with JSON Pointers.
+
+## Runtime integration
+
+`WotMaterializationCoordinator` compiles each resource's forms into a
+`WotBindingPlan` during **Prepare**, activates the plan only **after** the
+projection is committed as the active generation, and deactivates it **before**
+the projection is retired or unloaded.
+
+* **Strict mode** (`WotRegistryServerOptions.StrictBindings = true`) fails the
+ closure when any required form is unsupported or invalid.
+* **Degraded mode** materializes nodes with `BadConfigurationError` and emits a
+ `WoTBindingFailureEvent`. Validated-but-non-executable forms also degrade the
+ closure so their nodes are visible but flagged.
+* Binding capability snapshots populate the registry `SelectedBindings` node
+ and contribute to refresh unchanged-detection.
+* The legacy 1.02 `IWotAssetProviderFactory` provider model is preserved
+ untouched.
+
+## Registering binders and executors
+
+The planner binders are opt-in and replaceable:
+
+```csharp
+builder
+ .AddWotRegistryServer(o => o.StrictBindings = false)
+ .AddHttpWotBinding() // planners + HTTP executor
+ .AddModbusWotBinding() // + Modbus TCP executor
+ .AddMqttWotBinding() // + MQTT executor
+ .AddOpcUaWotBinding(o => o.SessionFactory = ConnectSessionAsync);
+```
+
+Each `AddWotBinding` registers the eight planner binders (idempotently)
+and its executor. Without any executor, `AddWotProtocolBinders()` still validates
+and compiles plans, materializing non-executable nodes.
+
+Replace or add binders directly:
+
+```csharp
+builder.AddWotBinder(new MyCustomBinder()); // custom planner
+builder.AddWotBindingExecutor(new MyCustomExecutor()); // custom executor
+builder.AddWotCredentialProvider(new VaultCredentialProvider());
+```
+
+Selection is deterministic: the registry evaluates binders in ordinal
+`id@version` order and chooses the highest-priority `WotBindingMatch`.
+
+## Writing a custom binder (code-behind)
+
+A third party contributes a binder as ordinary code. See the worked sample
+`Opc.Ua.WotCon.Binding.Samples.MemoryWotBinder` (a fictitious `mem://` protocol
+bound to an in-process key/value store). The pattern is:
+
+1. Derive from `WotProtocolBinderBase` and provide `Identity`, `Capability` and
+ the handled `Schemes`.
+2. Override `Match` (usually `MatchStandard(form, context, "yourv:")`) to claim
+ forms deterministically.
+3. Override `Compile` to validate the href/vocabulary and emit `WotCompiledForm`
+ entries with endpoint/addressing/operation/payload metadata and JSON-Pointer
+ diagnostics.
+4. Optionally implement `IWotBindingExecutor` returning an `IWotBindingChannel`
+ for read/write/observe/invoke.
+5. Register with `builder.AddWotBinder(...)` and
+ `builder.AddWotBindingExecutor(...)`.
+
+Because the planner is separate from the executor, a custom binding can ship as a
+validator first and gain execution later without any change to the core model,
+server or coordinator.
+
+## Intentionally unsupported operations
+
+* CoAP, BACnet, PROFINET and LoRaWAN ship as **planner-only** (non-executable) in
+ this build.
+* The Modbus binding does not support action invocation or events (Modbus has no
+ such concept); those operations return `BadNotSupported`.
+* The OPC UA executor implements read/write/invoke and **native** observe /
+ event subscription (a `Subscription` / `MonitoredItem` pair per channel, Part
+ 4 §5.12 / §5.13) — see [Operation coverage](#operation-coverage) below.
+* The MQTT executor implements publish/subscribe; request/response RPC with a
+ dedicated response topic is not modelled (actions publish only).
+
+## Transport security
+
+The executable bindings fail closed and never downgrade a secure form to an
+insecure transport:
+
+* **MQTT** — an `mqtts://` href always enables TLS and defaults to port 8883; an
+ `mqtt://` href stays explicit plaintext (port 1883). Username / password,
+ the TLS client certificate and TLS trust anchors are resolved through the
+ `IWotCredentialProvider`; a form that declares a security scheme is refused
+ when the provider resolves no credential. Username / password over plaintext
+ `mqtt://` is refused unless `MqttWotBindingOptions.AllowCredentialsOverPlaintext`
+ is set.
+* **HTTP** — the executor-owned `HttpClient` disables automatic redirects and
+ applies a bounded, origin-aware redirect policy: custom header and query
+ credentials are stripped across origins, redirect loops and non-`http(s)`
+ schemes are refused, an `https`→`http` downgrade is refused unless
+ `AllowInsecureRedirectDowngrade` is set, and the hop count is capped by
+ `MaxAutomaticRedirects` (default 5). A caller-supplied client used with a
+ credential-bearing form fails closed unless
+ `HttpWotBindingOptions.CallerClientHandlesRedirectSafety` confirms the client
+ handles redirects without leaking credentials.
+* **Modbus** — `modv:address` must be 0–65535 and the addressed range
+ (`address + quantity - 1`) must stay in the 16-bit space; function-only forms
+ map exactly onto function codes 1, 2, 3, 4, 5, 6, 15 and 16, and
+ op/function (or entity/function) mismatches are rejected. The executor
+ re-validates the range before narrowing to `ushort` / `byte`.
+
+## Operation coverage (OPC UA executor)
+
+| Operation | Mechanism |
+| --- | --- |
+| `readproperty` | `Read` service (`ISession.ReadValueAsync`). |
+| `writeproperty` | `Write` service; the mapped `StatusCode` is preserved. |
+| `observeproperty` | A native data-change `MonitoredItem` (`AttributeId = Value`, queue size 1) on a dedicated `Subscription`; no client-side polling. |
+| `invokeaction` | `Call` service; the method NodeId is `uav:id` and its owner object is resolved from `uav:componentOf`. |
+| `subscribeevent` | A native event `MonitoredItem` (`AttributeId = EventNotifier`) selecting `EventId`, `EventType`, `SourceNode`, `SourceName`, `Time`, `ReceiveTime`, `Message` and `Severity`, plus any `uav:eventFields`-authored extra select clauses. Every selected field is delivered in `WotNotification.EventFields`, keyed by its browse path, with the event's own `Time` / `ReceiveTime` as the source / server timestamp. |
+
+Both subscription kinds share one code path: a dedicated `Subscription` is
+created per channel subscription, its `MonitoredItem` is disposed and the
+subscription removed from the session (`ISession.RemoveSubscriptionAsync`) when
+the returned `IWotSubscription` is disposed, so no session or subscription is
+leaked — including when creation fails partway through.
+
+A compiled form's NodeId (`uav:id`, and `uav:componentOf` for actions) is
+resolved with `NodeId.Parse` for the plain `ns=` / `i=` / `s=` / `g=` / `b=`
+forms; a portable NodeId carrying an `nsu=` namespace URI is parsed as an
+`ExpandedNodeId` and resolved against the connected session's namespace table,
+since `NodeId.Parse` alone cannot resolve a namespace URI without one.
diff --git a/src/Opc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs b/src/Opc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs
index 42b6e4c37c..ec45b10c7d 100644
--- a/src/Opc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs
+++ b/src/Opc.Ua.Server/Hosting/HostedNodeManagerLifecycle.cs
@@ -68,6 +68,38 @@ public ValueTask ReloadAsync(
return Current.ReloadAsync(registration, replacement, ct);
}
+ public ValueTask ShadowReloadAsync(
+ NodeManagerRegistration registration,
+ IAsyncNodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ return Current.ShadowReloadAsync(registration, replacement, ct);
+ }
+
+ public ValueTask ShadowReloadAsync(
+ NodeManagerRegistration registration,
+ INodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ return Current.ShadowReloadAsync(registration, replacement, ct);
+ }
+
+ public ValueTask ImmediateReloadAsync(
+ NodeManagerRegistration registration,
+ IAsyncNodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ return Current.ImmediateReloadAsync(registration, replacement, ct);
+ }
+
+ public ValueTask ImmediateReloadAsync(
+ NodeManagerRegistration registration,
+ INodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ return Current.ImmediateReloadAsync(registration, replacement, ct);
+ }
+
public ValueTask RemoveAsync(
NodeManagerRegistration registration,
CancellationToken ct = default)
diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/IDynamicNodeManagerHost.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/IDynamicNodeManagerHost.cs
index 18f91a7e17..629dbd612f 100644
--- a/src/Opc.Ua.Server/NodeManager/Lifecycle/IDynamicNodeManagerHost.cs
+++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/IDynamicNodeManagerHost.cs
@@ -47,6 +47,7 @@ ValueTask PublishAsync(
ValueTask ReplaceAsync(
IAsyncNodeManager current,
PreparedNodeManager replacement,
+ bool allowActiveMonitoredItems = false,
CancellationToken ct = default);
ValueTask CommitAsync(
@@ -68,6 +69,14 @@ ValueTask RollbackAsync(
CancellationToken ct = default);
void Release(IAsyncNodeManager nodeManager);
+
+ ///
+ /// Registers a callback the host invokes (from an ownership-sensitive monitored
+ /// item request such as Delete) once monitored items owned by a shadow-retired
+ /// generation may have drained. The callback must not tear down anything inline;
+ /// it schedules cleanup off the request path.
+ ///
+ void SetRetiredGenerationDrainObserver(Action? observer);
}
internal sealed class PreparedNodeManager
@@ -91,5 +100,14 @@ public PreparedNodeManager(
public IAsyncNodeManager? ReplacedNodeManager { get; set; }
public Dictionary>? ReplacedExternalReferences { get; set; }
+
+ ///
+ /// Gets or sets whether may still own active
+ /// monitored items when this replacement is committed. Set by
+ /// for a shadow reload; the
+ /// replaced generation is preserved for its existing monitored items and is torn
+ /// down only after they drain.
+ ///
+ public bool AllowActiveMonitoredItems { get; set; }
}
}
diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerLifecycle.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerLifecycle.cs
index 5f6361dcc3..9e7e1655c2 100644
--- a/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerLifecycle.cs
+++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerLifecycle.cs
@@ -72,6 +72,50 @@ ValueTask ReloadAsync(
INodeManagerFactory replacement,
CancellationToken ct = default);
+ ///
+ /// Replaces a live registration with a new asynchronous factory generation while
+ /// allowing the current generation to keep serving monitored items that were
+ /// already created on it. New service requests are atomically routed to the
+ /// replacement generation as soon as it is committed; the current generation is
+ /// retained only for its existing monitored items and any request or continuation
+ /// point that already captured it, and is disposed automatically once they drain.
+ ///
+ ValueTask ShadowReloadAsync(
+ NodeManagerRegistration registration,
+ IAsyncNodeManagerFactory replacement,
+ CancellationToken ct = default);
+
+ ///
+ /// Replaces a live registration with a new synchronous factory generation while
+ /// allowing the current generation to keep serving monitored items that were
+ /// already created on it. New service requests are atomically routed to the
+ /// replacement generation as soon as it is committed; the current generation is
+ /// retained only for its existing monitored items and any request or continuation
+ /// point that already captured it, and is disposed automatically once they drain.
+ ///
+ ValueTask ShadowReloadAsync(
+ NodeManagerRegistration registration,
+ INodeManagerFactory replacement,
+ CancellationToken ct = default);
+
+ ///
+ /// Replaces a live registration and immediately invalidates monitored items
+ /// owned by the previous generation with .
+ ///
+ ValueTask ImmediateReloadAsync(
+ NodeManagerRegistration registration,
+ IAsyncNodeManagerFactory replacement,
+ CancellationToken ct = default);
+
+ ///
+ /// Replaces a live registration and immediately invalidates monitored items
+ /// owned by the previous generation with .
+ ///
+ ValueTask ImmediateReloadAsync(
+ NodeManagerRegistration registration,
+ INodeManagerFactory replacement,
+ CancellationToken ct = default);
+
///
/// Removes a live registration from the server.
///
diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerMonitoredItemTracker.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerMonitoredItemTracker.cs
index 5be7281611..bba909febb 100644
--- a/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerMonitoredItemTracker.cs
+++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/INodeManagerMonitoredItemTracker.cs
@@ -39,4 +39,27 @@ public interface INodeManagerMonitoredItemTracker
///
bool HasMonitoredItems(IAsyncNodeManager nodeManager);
}
+
+ ///
+ /// Internal extension that invalidates monitored items owned by a NodeManager
+ /// before an immediate generation retirement.
+ ///
+ internal interface INodeManagerMonitoredItemRetirementTracker
+ {
+ ///
+ /// Returns whether every monitored item owned by the NodeManager supports
+ /// immediate retirement.
+ ///
+ bool CanRetireMonitoredItems(IAsyncNodeManager nodeManager);
+
+ ///
+ /// Invalidates all monitored items owned by the NodeManager.
+ ///
+ void RetireMonitoredItems(IAsyncNodeManager nodeManager, ServiceResult error);
+
+ ///
+ /// Releases owner references from retired monitored items.
+ ///
+ void DetachRetiredMonitoredItems(IAsyncNodeManager nodeManager);
+ }
}
diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs
index 6e7ae360e7..29f1c25ba7 100644
--- a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs
+++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerLifecycle.cs
@@ -113,10 +113,13 @@ internal async ValueTask CompleteShutdownAsync(
try
{
RegistrationState[] registrations;
+ RetiredNodeManager[] retired;
lock (m_registrationLock)
{
registrations = [.. m_registrations.Values];
m_registrations.Clear();
+ retired = [.. m_retiredNodeManagers];
+ m_retiredNodeManagers.Clear();
}
var host =
@@ -127,6 +130,17 @@ internal async ValueTask CompleteShutdownAsync(
await DisposeNodeManagerAsync(registration.Prepared.NodeManager)
.ConfigureAwait(false);
}
+
+ // The server itself tears down every session, subscription, and
+ // monitored item during shutdown, so a shadow-reloaded generation that
+ // is still draining outside of shutdown is safe to dispose here rather
+ // than left to leak.
+ foreach (RetiredNodeManager retiredNodeManager in retired)
+ {
+ host?.Release(retiredNodeManager.NodeManager);
+ await DisposeNodeManagerAsync(retiredNodeManager.NodeManager)
+ .ConfigureAwait(false);
+ }
}
finally
{
@@ -179,6 +193,7 @@ public ValueTask ReloadAsync(
return ReloadCoreAsync(
registration,
replacement.CreateAsync,
+ ReloadRetirementMode.RequireNoActiveMonitoredItems,
ct);
}
@@ -197,6 +212,81 @@ public ValueTask ReloadAsync(
registration,
(server, configuration, _) => new ValueTask(
replacement.Create(server, configuration).ToAsyncNodeManager()),
+ ReloadRetirementMode.RequireNoActiveMonitoredItems,
+ ct);
+ }
+
+ ///
+ public ValueTask ShadowReloadAsync(
+ NodeManagerRegistration registration,
+ IAsyncNodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ return ReloadCoreAsync(
+ registration,
+ replacement.CreateAsync,
+ ReloadRetirementMode.Graceful,
+ ct);
+ }
+
+ ///
+ public ValueTask ShadowReloadAsync(
+ NodeManagerRegistration registration,
+ INodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ return ReloadCoreAsync(
+ registration,
+ (server, configuration, _) => new ValueTask(
+ replacement.Create(server, configuration).ToAsyncNodeManager()),
+ ReloadRetirementMode.Graceful,
+ ct);
+ }
+
+ ///
+ public ValueTask ImmediateReloadAsync(
+ NodeManagerRegistration registration,
+ IAsyncNodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ return ReloadCoreAsync(
+ registration,
+ replacement.CreateAsync,
+ ReloadRetirementMode.Immediate,
+ ct);
+ }
+
+ ///
+ public ValueTask ImmediateReloadAsync(
+ NodeManagerRegistration registration,
+ INodeManagerFactory replacement,
+ CancellationToken ct = default)
+ {
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ return ReloadCoreAsync(
+ registration,
+ (server, configuration, _) => new ValueTask(
+ replacement.Create(server, configuration).ToAsyncNodeManager()),
+ ReloadRetirementMode.Immediate,
ct);
}
@@ -552,6 +642,7 @@ await NotifyNamespaceTableChangedAsync(
private async ValueTask ReloadCoreAsync(
NodeManagerRegistration registration,
CreateNodeManagerAsync createNodeManager,
+ ReloadRetirementMode retirementMode,
CancellationToken ct)
{
if (registration is null)
@@ -568,13 +659,30 @@ private async ValueTask ReloadCoreAsync(
IServerInternal? server = null;
IDynamicNodeManagerHost? host = null;
int namespaceCountBefore = 0;
+ bool allowActiveMonitoredItems =
+ retirementMode != ReloadRetirementMode.RequireNoActiveMonitoredItems;
+ bool deferForActiveMonitoredItems =
+ retirementMode == ReloadRetirementMode.Graceful;
+ ServiceResult? immediateRetirementError =
+ retirementMode == ReloadRetirementMode.Immediate
+ ? new ServiceResult(StatusCodes.BadNodeIdUnknown)
+ : null;
try
{
(server, host) = GetRunningServer();
await CleanupRetiredNodeManagersAsync(server, host).ConfigureAwait(false);
namespaceCountBefore = server.NamespaceUris.Count;
current = GetCurrentState(registration);
- EnsureNoActiveMonitoredItems(server, current.Prepared.NodeManager);
+ if (!allowActiveMonitoredItems)
+ {
+ EnsureNoActiveMonitoredItems(server, current.Prepared.NodeManager);
+ }
+ else if (immediateRetirementError is not null)
+ {
+ EnsureImmediateRetirementSupported(
+ server,
+ current.Prepared.NodeManager);
+ }
replacementManager = await createNodeManager(
server,
@@ -613,7 +721,11 @@ await m_server
ct).ConfigureAwait(false);
await host
- .ReplaceAsync(current.Prepared.NodeManager, replacement, ct)
+ .ReplaceAsync(
+ current.Prepared.NodeManager,
+ replacement,
+ allowActiveMonitoredItems,
+ ct)
.ConfigureAwait(false);
await CommitWithReconciliationAsync(
server,
@@ -621,7 +733,16 @@ await CommitWithReconciliationAsync(
replacement,
replacementManager,
bindings,
- ct).ConfigureAwait(false);
+ ct,
+ immediateRetirementError is null
+ ? null
+ : () =>
+ {
+ EnsureImmediateRetirementSupported(
+ server,
+ current.Prepared.NodeManager);
+ return default;
+ }).ConfigureAwait(false);
current.Prepared.Published = false;
var nextRegistration = new NodeManagerRegistration(
current.Registration.Id,
@@ -637,11 +758,23 @@ await CommitWithReconciliationAsync(
var retired = new RetiredNodeManager(
current.Prepared.NodeManager,
droppedInboundReferences,
- needsDetachment: true);
+ needsDetachment: true,
+ allowActiveMonitoredItems: deferForActiveMonitoredItems,
+ immediateRetirementError: immediateRetirementError);
lock (m_registrationLock)
{
m_retiredNodeManagers.Add(retired);
}
+
+ // Register the drain observer so the host can trigger prompt cleanup once a
+ // shadow-retired generation's monitored items drain, rather than waiting for
+ // the next lifecycle operation or server shutdown.
+ if (deferForActiveMonitoredItems)
+ {
+ host.SetRetiredGenerationDrainObserver(
+ ScheduleRetiredGenerationDrainCleanup);
+ }
+ Exception? postCommitFailure = null;
try
{
await server.RequestManager
@@ -652,31 +785,59 @@ await ReconcileBindingsAsync(
replacementManager,
bindings,
CancellationToken.None).ConfigureAwait(false);
- await CleanupRetiredNodeManagerAsync(server, host, retired)
+ bool cleaned = await CleanupRetiredNodeManagerAsync(server, host, retired)
.ConfigureAwait(false);
- lock (m_registrationLock)
+ if (cleaned)
{
- m_retiredNodeManagers.Remove(retired);
+ lock (m_registrationLock)
+ {
+ m_retiredNodeManagers.Remove(retired);
+ }
}
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
- throw new InvalidOperationException(
- "The replacement NodeManager is live, but the retired generation " +
- "could not be cleaned up. A later lifecycle operation will retry cleanup.",
- ex);
+ postCommitFailure = ex;
}
- await NotifyCommittedChangeAsync(
- server,
- "reloaded",
- namespaceCountBefore,
- CancellationToken.None,
- semanticChanges).ConfigureAwait(false);
+ try
+ {
+ await NotifyCommittedChangeAsync(
+ server,
+ retirementMode switch
+ {
+ ReloadRetirementMode.Graceful => "shadow-reloaded",
+ ReloadRetirementMode.Immediate => "immediate-reloaded",
+ _ => "reloaded"
+ },
+ namespaceCountBefore,
+ CancellationToken.None,
+ semanticChanges).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ postCommitFailure = postCommitFailure is null
+ ? ex
+ : new AggregateException(postCommitFailure, ex);
+ }
+
+ if (postCommitFailure is not null)
+ {
+ throw new NodeManagerReloadCommittedException(
+ nextRegistration,
+ "The replacement NodeManager is live, but reload completion failed. " +
+ "A later lifecycle operation will retry retired-generation cleanup.",
+ postCommitFailure);
+ }
return nextRegistration;
}
catch (Exception ex) when (ex is not OutOfMemoryException)
{
+ if (ex is NodeManagerReloadCommittedException)
+ {
+ throw;
+ }
+
Exception? cleanupException = null;
if (replacement is not null &&
!replacement.Published &&
@@ -689,6 +850,7 @@ await NotifyCommittedChangeAsync(
}
NodeManagerRegistration? retainedRegistration = null;
+ NodeManagerRegistration? committedRegistration = null;
Exception? recoveryException = null;
if (replacement?.Published == true &&
replacementManager is not null &&
@@ -699,13 +861,16 @@ server is not null &&
bool registrationAlreadyUpdated;
lock (m_registrationLock)
{
- registrationAlreadyUpdated =
- m_registrations.TryGetValue(
- current.Registration.Id,
- out RegistrationState? retainedState) &&
+ registrationAlreadyUpdated = m_registrations.TryGetValue(
+ current.Registration.Id,
+ out RegistrationState? retainedState) &&
ReferenceEquals(
retainedState.Registration.NodeManager,
replacementManager);
+ if (registrationAlreadyUpdated)
+ {
+ committedRegistration = retainedState!.Registration;
+ }
}
if (!registrationAlreadyUpdated)
@@ -724,7 +889,9 @@ server is not null &&
new RetiredNodeManager(
current.Prepared.NodeManager,
droppedInboundReferences,
- needsDetachment: true));
+ needsDetachment: true,
+ allowActiveMonitoredItems: deferForActiveMonitoredItems,
+ immediateRetirementError: immediateRetirementError));
}
try
@@ -756,6 +923,15 @@ await ReconcileBindingsAsync(
}
}
+ if (committedRegistration is not null)
+ {
+ throw new NodeManagerReloadCommittedException(
+ committedRegistration,
+ "The replacement NodeManager is live, but reload completion failed. " +
+ "A later lifecycle operation will retry retired-generation cleanup.",
+ ex);
+ }
+
if (server is not null)
{
if (replacementManager is not null)
@@ -785,7 +961,8 @@ await NotifyNamespaceTableChangedAsync(
{
failures.Add(recoveryException);
}
- throw new InvalidOperationException(
+ throw new NodeManagerReloadCommittedException(
+ retainedRegistration,
"NodeManager reload failed during rollback. " +
"The replacement generation was retained and is available " +
"from Registrations for retry or removal.",
@@ -878,6 +1055,17 @@ private RegistrationState GetCurrentState(NodeManagerRegistration registration)
private static void EnsureNoActiveMonitoredItems(
IServerInternal server,
IAsyncNodeManager nodeManager)
+ {
+ if (HasActiveMonitoredItems(server, nodeManager))
+ {
+ throw new InvalidOperationException(
+ "The NodeManager cannot be reloaded or removed while it owns monitored items.");
+ }
+ }
+
+ private static bool HasActiveMonitoredItems(
+ IServerInternal server,
+ IAsyncNodeManager nodeManager)
{
foreach (ISubscription subscription in server.SubscriptionManager.GetSubscriptions())
{
@@ -892,8 +1080,107 @@ private static void EnsureNoActiveMonitoredItems(
}
if (tracker.HasMonitoredItems(nodeManager))
{
- throw new InvalidOperationException(
- "The NodeManager cannot be reloaded or removed while it owns monitored items.");
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static void EnsureImmediateRetirementSupported(
+ IServerInternal server,
+ IAsyncNodeManager nodeManager)
+ {
+ foreach (ISubscription subscription in server.SubscriptionManager.GetSubscriptions())
+ {
+ if (subscription.MonitoredItemCount == 0)
+ {
+ continue;
+ }
+ if (subscription is not INodeManagerMonitoredItemTracker tracker)
+ {
+ throw new NotSupportedException(
+ "The configured subscription cannot verify NodeManager ownership.");
+ }
+ if (!tracker.HasMonitoredItems(nodeManager))
+ {
+ continue;
+ }
+ if (subscription is not INodeManagerMonitoredItemRetirementTracker retirementTracker ||
+ !retirementTracker.CanRetireMonitoredItems(nodeManager))
+ {
+ throw new NotSupportedException(
+ "The configured subscription cannot retire NodeManager-owned monitored items.");
+ }
+ }
+ }
+
+ private static async ValueTask RetireMonitoredItemsAsync(
+ IServerInternal server,
+ IAsyncNodeManager nodeManager,
+ ServiceResult error,
+ bool detachOwner = false)
+ {
+ if (server.NodeManager is INodeManagerMutationCoordinator coordinator)
+ {
+ await coordinator.ExecuteMonitoredItemMutationAsync(
+ () =>
+ {
+ RetireMonitoredItemsCore(server, nodeManager, error);
+ if (detachOwner)
+ {
+ DetachRetiredMonitoredItemsCore(server, nodeManager);
+ }
+ return new ValueTask(true);
+ },
+ CancellationToken.None).ConfigureAwait(false);
+ return;
+ }
+
+ RetireMonitoredItemsCore(server, nodeManager, error);
+ if (detachOwner)
+ {
+ DetachRetiredMonitoredItemsCore(server, nodeManager);
+ }
+ }
+
+ private static void RetireMonitoredItemsCore(
+ IServerInternal server,
+ IAsyncNodeManager nodeManager,
+ ServiceResult error)
+ {
+ foreach (ISubscription subscription in server.SubscriptionManager.GetSubscriptions())
+ {
+ if (subscription.MonitoredItemCount == 0)
+ {
+ continue;
+ }
+ if (subscription is not INodeManagerMonitoredItemTracker tracker)
+ {
+ throw new NotSupportedException(
+ "The configured subscription cannot verify NodeManager ownership.");
+ }
+ if (!tracker.HasMonitoredItems(nodeManager))
+ {
+ continue;
+ }
+ if (subscription is not INodeManagerMonitoredItemRetirementTracker retirementTracker)
+ {
+ throw new NotSupportedException(
+ "The configured subscription cannot retire NodeManager-owned monitored items.");
+ }
+ retirementTracker.RetireMonitoredItems(nodeManager, error);
+ }
+ }
+
+ private static void DetachRetiredMonitoredItemsCore(
+ IServerInternal server,
+ IAsyncNodeManager nodeManager)
+ {
+ foreach (ISubscription subscription in server.SubscriptionManager.GetSubscriptions())
+ {
+ if (subscription is INodeManagerMonitoredItemRetirementTracker retirementTracker)
+ {
+ retirementTracker.DetachRetiredMonitoredItems(nodeManager);
}
}
}
@@ -917,15 +1204,23 @@ private static async ValueTask CommitWithReconciliationAsync(
PreparedNodeManager prepared,
IAsyncNodeManager nodeManager,
ServerBindings bindings,
- CancellationToken ct)
+ CancellationToken ct,
+ Func? beforeCommit = null)
{
await host.CommitAsync(
prepared,
- () => ReconcileBindingsAsync(
- server,
- nodeManager,
- bindings,
- ct),
+ async () =>
+ {
+ if (beforeCommit is not null)
+ {
+ await beforeCommit().ConfigureAwait(false);
+ }
+ await ReconcileBindingsAsync(
+ server,
+ nodeManager,
+ bindings,
+ ct).ConfigureAwait(false);
+ },
ct).ConfigureAwait(false);
}
@@ -1668,6 +1963,117 @@ await host
return unbindException ?? rollbackException;
}
+ ///
+ /// Schedules a background pass that disposes any shadow-retired generation whose
+ /// monitored items have drained. Invoked by the host from an ownership-sensitive
+ /// monitored item request (for example, the Delete that drains the last item), so
+ /// the teardown must never run inline on the request path: it is dispatched to the
+ /// thread pool with the request's execution context suppressed and coordinated
+ /// through the lifecycle semaphore, exactly like an explicit lifecycle operation.
+ /// If cleanup cannot complete now (for example, a lifecycle operation is already
+ /// running), a later lifecycle operation or shutdown retries it.
+ ///
+ private void ScheduleRetiredGenerationDrainCleanup()
+ {
+ if (m_disposed || m_shuttingDown)
+ {
+ return;
+ }
+ lock (m_registrationLock)
+ {
+ if (m_retiredNodeManagers.Count == 0)
+ {
+ return;
+ }
+ }
+
+ // Suppress the triggering request's execution context so the background pass is
+ // not observed as running inside an OPC UA request callback.
+ bool restoreFlow = false;
+ try
+ {
+ if (!ExecutionContext.IsFlowSuppressed())
+ {
+ ExecutionContext.SuppressFlow();
+ restoreFlow = true;
+ }
+ _ = Task.Run(DrainRetiredGenerationsAsync);
+ }
+ finally
+ {
+ if (restoreFlow)
+ {
+ ExecutionContext.RestoreFlow();
+ }
+ }
+ }
+
+ private async Task DrainRetiredGenerationsAsync()
+ {
+ try
+ {
+ if (m_disposed || m_shuttingDown)
+ {
+ return;
+ }
+
+ await m_lifecycleSemaphore
+ .WaitAsync(CancellationToken.None)
+ .ConfigureAwait(false);
+ try
+ {
+ if (m_disposed || m_shuttingDown)
+ {
+ return;
+ }
+ if (!TryGetRunningServer(
+ out IServerInternal server,
+ out IDynamicNodeManagerHost host))
+ {
+ return;
+ }
+
+ await CleanupRetiredNodeManagersAsync(server, host)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ m_lifecycleSemaphore.Release();
+ }
+ }
+ catch (Exception ex) when (ex is not OutOfMemoryException)
+ {
+ // Prompt cleanup is best-effort. Any generation that could not be torn down
+ // here is retried by the next lifecycle operation or server shutdown.
+ }
+ }
+
+ private bool TryGetRunningServer(
+ out IServerInternal server,
+ out IDynamicNodeManagerHost host)
+ {
+ server = null!;
+ host = null!;
+ if (m_disposed || m_shuttingDown)
+ {
+ return false;
+ }
+ if (m_server.CurrentState != ServerState.Running)
+ {
+ return false;
+ }
+
+ IServerInternal runningServer = m_server.CurrentInstance;
+ if (runningServer.NodeManager is not IDynamicNodeManagerHost dynamicHost)
+ {
+ return false;
+ }
+
+ server = runningServer;
+ host = dynamicHost;
+ return true;
+ }
+
private async ValueTask CleanupRetiredNodeManagersAsync(
IServerInternal server,
IDynamicNodeManagerHost host)
@@ -1680,29 +2086,67 @@ private async ValueTask CleanupRetiredNodeManagersAsync(
foreach (RetiredNodeManager retiredNodeManager in retired)
{
- await CleanupRetiredNodeManagerAsync(
+ bool cleaned = await CleanupRetiredNodeManagerAsync(
server,
host,
retiredNodeManager).ConfigureAwait(false);
- lock (m_registrationLock)
+ if (cleaned)
{
- m_retiredNodeManagers.Remove(retiredNodeManager);
+ lock (m_registrationLock)
+ {
+ m_retiredNodeManagers.Remove(retiredNodeManager);
+ }
}
}
}
- private static async ValueTask CleanupRetiredNodeManagerAsync(
+ ///
+ /// Detaches and destroys a retired NodeManager generation, returning true
+ /// once fully cleaned up. A shadow-reloaded generation that still owns active
+ /// monitored items is left untouched (requests, continuation points, and
+ /// monitored items that already captured it keep working) and false is
+ /// returned so the caller retries cleanup on a later opportunity. An immediate
+ /// retirement instead invalidates owned monitored items before detachment; neither
+ /// policy deletes the client's subscription.
+ ///
+ private static async ValueTask CleanupRetiredNodeManagerAsync(
IServerInternal server,
IDynamicNodeManagerHost host,
RetiredNodeManager retired)
{
if (retired.NeedsDetachment)
{
+ if (retired.AllowActiveMonitoredItems &&
+ HasActiveMonitoredItems(server, retired.NodeManager))
+ {
+ return false;
+ }
+
+ if (retired.ImmediateRetirementError is not null)
+ {
+ await RetireMonitoredItemsAsync(
+ server,
+ retired.NodeManager,
+ retired.ImmediateRetirementError)
+ .ConfigureAwait(false);
+ }
InvalidateContinuationPoints(server, retired.NodeManager);
await server.RequestManager
.WaitForCurrentRequestsAsync(CancellationToken.None)
.ConfigureAwait(false);
InvalidateContinuationPoints(server, retired.NodeManager);
+ if (retired.ImmediateRetirementError is not null)
+ {
+ // A request that captured the old routing generation before the
+ // switch may have completed monitored-item creation during the
+ // drain. Retire that late item before detaching the owner.
+ await RetireMonitoredItemsAsync(
+ server,
+ retired.NodeManager,
+ retired.ImmediateRetirementError,
+ detachOwner: true)
+ .ConfigureAwait(false);
+ }
EnsureNoActiveMonitoredItems(server, retired.NodeManager);
await UnbindFromServerAsync(
server,
@@ -1729,6 +2173,7 @@ await host
.ConfigureAwait(false);
RebuildActiveTypeTree(server);
await DisposeNodeManagerAsync(retired.NodeManager).ConfigureAwait(false);
+ return true;
}
private delegate ValueTask CreateNodeManagerAsync(
@@ -1736,6 +2181,13 @@ private delegate ValueTask CreateNodeManagerAsync(
ApplicationConfiguration configuration,
CancellationToken ct);
+ private enum ReloadRetirementMode
+ {
+ RequireNoActiveMonitoredItems,
+ Graceful,
+ Immediate
+ }
+
private sealed class RegistrationState
{
public RegistrationState(
@@ -1778,11 +2230,15 @@ private sealed class RetiredNodeManager
public RetiredNodeManager(
IAsyncNodeManager nodeManager,
List pendingReferences,
- bool needsDetachment)
+ bool needsDetachment,
+ bool allowActiveMonitoredItems = false,
+ ServiceResult? immediateRetirementError = null)
{
NodeManager = nodeManager;
PendingReferences = pendingReferences;
NeedsDetachment = needsDetachment;
+ AllowActiveMonitoredItems = allowActiveMonitoredItems;
+ ImmediateRetirementError = immediateRetirementError;
}
public IAsyncNodeManager NodeManager { get; }
@@ -1790,6 +2246,18 @@ public RetiredNodeManager(
public List PendingReferences { get; }
public bool NeedsDetachment { get; set; }
+
+ ///
+ /// Gets whether this generation was retired by a shadow reload and may still
+ /// own active monitored items. Cleanup is deferred rather than rejected while
+ /// this holds true and monitored items remain.
+ ///
+ public bool AllowActiveMonitoredItems { get; }
+
+ ///
+ /// Gets the status queued to monitored items before immediate cleanup.
+ ///
+ public ServiceResult? ImmediateRetirementError { get; }
}
private readonly StandardServer m_server;
diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerReloadCommittedException.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerReloadCommittedException.cs
new file mode 100644
index 0000000000..ea43468266
--- /dev/null
+++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerReloadCommittedException.cs
@@ -0,0 +1,59 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Diagnostics.CodeAnalysis;
+
+namespace Opc.Ua.Server
+{
+ ///
+ /// Reports a reload failure that occurred after the replacement generation
+ /// was committed and provides its authoritative registration.
+ ///
+ [SuppressMessage(
+ "Design",
+ "CA1032:Implement standard exception constructors",
+ Justification = "A committed reload exception is meaningful only with its authoritative registration.")]
+ public sealed class NodeManagerReloadCommittedException : InvalidOperationException
+ {
+ /// Initializes the exception.
+ public NodeManagerReloadCommittedException(
+ NodeManagerRegistration registration,
+ string message,
+ Exception innerException)
+ : base(message, innerException)
+ {
+ Registration = registration ?? throw new ArgumentNullException(nameof(registration));
+ }
+
+ /// Gets the committed replacement registration.
+ public NodeManagerRegistration Registration { get; }
+ }
+}
diff --git a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerRoutingTable.cs b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerRoutingTable.cs
index 37a522c6b4..c15020dfb1 100644
--- a/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerRoutingTable.cs
+++ b/src/Opc.Ua.Server/NodeManager/Lifecycle/NodeManagerRoutingTable.cs
@@ -441,6 +441,24 @@ public bool IsVisible(IAsyncNodeManager nodeManager)
ReferenceEquals(manager, nodeManager));
}
+ ///
+ /// Returns whether the given NodeManager is still registered (visible or hidden).
+ /// A shadow-retired generation removed from the routing table returns false,
+ /// which callers use to detect that a monitored item is owned by a retired
+ /// generation rather than a live routing-table manager.
+ ///
+ public bool Contains(IAsyncNodeManager nodeManager)
+ {
+ if (nodeManager is null)
+ {
+ return false;
+ }
+
+ RoutingSnapshot snapshot = Volatile.Read(ref m_snapshot);
+ return snapshot.NodeManagers.Any(manager =>
+ ReferenceEquals(manager, nodeManager));
+ }
+
public void SetVisible(
IAsyncNodeManager nodeManager,
bool visible)
diff --git a/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs b/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs
index c57eb22847..a05352d2e1 100644
--- a/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs
+++ b/src/Opc.Ua.Server/NodeManager/MasterNodeManager.cs
@@ -517,6 +517,7 @@ async ValueTask IDynamicNodeManagerHost.PublishAsync(
async ValueTask IDynamicNodeManagerHost.ReplaceAsync(
IAsyncNodeManager current,
PreparedNodeManager replacement,
+ bool allowActiveMonitoredItems,
CancellationToken ct)
{
if (current is null)
@@ -542,6 +543,7 @@ async ValueTask IDynamicNodeManagerHost.ReplaceAsync(
}
replacement.ReplacedNodeManager = current;
replacement.ReplacedExternalReferences = currentExternalReferences;
+ replacement.AllowActiveMonitoredItems = allowActiveMonitoredItems;
replacement.Staged = true;
}
finally
@@ -591,8 +593,11 @@ private async ValueTask CommitPreparedNodeManagerAsync(
}
else
{
- EnsureNoActiveMonitoredItems(
- prepared.ReplacedNodeManager);
+ if (!prepared.AllowActiveMonitoredItems)
+ {
+ EnsureNoActiveMonitoredItems(
+ prepared.ReplacedNodeManager);
+ }
await CommitReplacementAsync(prepared).ConfigureAwait(false);
}
prepared.Staged = false;
@@ -767,6 +772,11 @@ void IDynamicNodeManagerHost.Release(IAsyncNodeManager nodeManager)
}
}
+ void IDynamicNodeManagerHost.SetRetiredGenerationDrainObserver(Action? observer)
+ {
+ m_retiredGenerationDrainObserver = observer;
+ }
+
async ValueTask INodeManagerMutationCoordinator
.ExecuteMonitoredItemMutationAsync(
Func> mutation,
@@ -3963,6 +3973,14 @@ public virtual async ValueTask ModifyMonitoredItemsAsync(
continue;
}
+ ServiceResult? retirementError = GetRetirementError(monitoredItems[ii]);
+ if (retirementError is not null)
+ {
+ errors[ii] = retirementError;
+ itemsToModify[ii].Processed = true;
+ continue;
+ }
+
// validate request parameters.
errors[ii] = ValidateMonitoredItemModifyRequest(itemsToModify[ii])!;
@@ -3991,19 +4009,19 @@ await ModifyMonitoredItemsForEventsAsync(
cancellationToken)
.ConfigureAwait(false);
- // let each node manager figure out which items it owns.
- foreach (IAsyncNodeManager nodeManager in m_nodeManagers)
- {
- await nodeManager.ModifyMonitoredItemsAsync(
- context,
- timestampsToReturn,
- monitoredItems,
- itemsToModify,
- errors,
- filterResults,
- cancellationToken)
- .ConfigureAwait(false);
- }
+ // let each owning node manager modify the items it created. Data monitored
+ // items are dispatched to their recorded owning NodeManager (grouped by
+ // owner) rather than to visible routing-table managers only, so items still
+ // owned by a shadow-retired generation are handled by that generation.
+ await DispatchModifyToOwningNodeManagersAsync(
+ context,
+ timestampsToReturn,
+ monitoredItems,
+ itemsToModify,
+ errors,
+ filterResults,
+ cancellationToken)
+ .ConfigureAwait(false);
// update results.
for (int ii = 0; ii < errors.Count; ii++)
@@ -4030,6 +4048,11 @@ private async ValueTask ModifyMonitoredItemsForEventsAsync(
{
for (int ii = 0; ii < itemsToModify.Count; ii++)
{
+ if (itemsToModify[ii].Processed)
+ {
+ continue;
+ }
+
// all event subscriptions are handled by the event manager.
if (monitoredItems[ii] is not IEventMonitoredItem monitoredItem ||
(monitoredItem.MonitoredItemType & MonitoredItemTypeMask.Events) == 0)
@@ -4153,22 +4176,28 @@ public virtual async ValueTask TransferMonitoredItemsAsync(
// preset results for unknown nodes
for (int ii = 0; ii < monitoredItems.Count; ii++)
{
- processedItems.Add(monitoredItems[ii] == null);
- errors[ii] = StatusCodes.BadMonitoredItemIdInvalid;
+ ServiceResult? retirementError = GetRetirementError(monitoredItems[ii]);
+ bool processed = monitoredItems[ii] == null || retirementError is not null;
+ processedItems.Add(processed);
+ errors[ii] = retirementError ?? StatusCodes.BadMonitoredItemIdInvalid;
}
- // call each node manager.
- foreach (IAsyncNodeManager nodeManager in m_nodeManagers)
- {
- await nodeManager.TransferMonitoredItemsAsync(
+ // call each owning node manager. Data monitored items are dispatched to their
+ // recorded owning NodeManager (grouped by owner) so items owned by a
+ // shadow-retired generation are transferred by that generation.
+ await DispatchDataMonitoredItemsToOwningNodeManagersAsync(
+ monitoredItems,
+ processedItems,
+ (owner, ownedItems) => owner.TransferMonitoredItemsAsync(
context,
sendInitialValues,
monitoredItems,
- processedItems,
+ ownedItems,
errors,
- cancellationToken)
- .ConfigureAwait(false);
- }
+ cancellationToken),
+ notifyRetiredGenerationDrain: false,
+ cancellationToken)
+ .ConfigureAwait(false);
}
///
@@ -4216,6 +4245,19 @@ public virtual async ValueTask DeleteMonitoredItemsAsync(
for (int ii = 0; ii < itemsToDelete.Count; ii++)
{
+ ServiceResult? retirementError = GetRetirementError(itemsToDelete[ii]);
+ if (retirementError is not null)
+ {
+ processedItems.Add(true);
+ if (itemsToDelete[ii] is IEventMonitoredItem eventMonitoredItem &&
+ (eventMonitoredItem.MonitoredItemType & MonitoredItemTypeMask.Events) != 0)
+ {
+ Server.EventManager.DeleteMonitoredItem(itemsToDelete[ii].Id);
+ }
+ errors[ii] = StatusCodes.Good;
+ continue;
+ }
+
processedItems.Add(ServiceResult.IsBad(errors[ii]) || itemsToDelete[ii] == null);
}
@@ -4229,17 +4271,21 @@ await DeleteMonitoredItemsForEventsAsync(
cancellationToken)
.ConfigureAwait(false);
- // call each node manager.
- foreach (IAsyncNodeManager nodeManager in m_nodeManagers)
- {
- await nodeManager.DeleteMonitoredItemsAsync(
+ // call each owning node manager. Data monitored items are dispatched to their
+ // recorded owning NodeManager (grouped by owner) so items owned by a
+ // shadow-retired generation are deleted by that generation, draining it.
+ await DispatchDataMonitoredItemsToOwningNodeManagersAsync(
+ itemsToDelete,
+ processedItems,
+ (owner, ownedItems) => owner.DeleteMonitoredItemsAsync(
context,
itemsToDelete,
- processedItems,
+ ownedItems,
errors,
- cancellationToken)
- .ConfigureAwait(false);
- }
+ cancellationToken),
+ notifyRetiredGenerationDrain: true,
+ cancellationToken)
+ .ConfigureAwait(false);
// fill results for unknown nodes.
for (int ii = 0; ii < errors.Count; ii++)
@@ -4264,6 +4310,11 @@ private async ValueTask DeleteMonitoredItemsForEventsAsync(
{
for (int ii = 0; ii < monitoredItems.Count; ii++)
{
+ if (processedItems[ii])
+ {
+ continue;
+ }
+
// all event subscriptions are handled by the event manager.
if (monitoredItems[ii] is not IEventMonitoredItem monitoredItem ||
(monitoredItem.MonitoredItemType & MonitoredItemTypeMask.Events) == 0)
@@ -4335,6 +4386,14 @@ public virtual async ValueTask SetMonitoringModeAsync(
for (int ii = 0; ii < itemsToModify.Count; ii++)
{
+ ServiceResult? retirementError = GetRetirementError(itemsToModify[ii]);
+ if (retirementError is not null)
+ {
+ processedItems.Add(true);
+ errors[ii] = retirementError;
+ continue;
+ }
+
processedItems.Add(ServiceResult.IsBad(errors[ii]) || itemsToModify[ii] == null);
}
@@ -4346,17 +4405,22 @@ public virtual async ValueTask SetMonitoringModeAsync(
processedItems,
errors);
- foreach (IAsyncNodeManager nodeManager in m_nodeManagers)
- {
- await nodeManager.SetMonitoringModeAsync(
+ // set the monitoring mode on each owning node manager. Data monitored items are
+ // dispatched to their recorded owning NodeManager (grouped by owner) so items
+ // owned by a shadow-retired generation are handled by that generation.
+ await DispatchDataMonitoredItemsToOwningNodeManagersAsync(
+ itemsToModify,
+ processedItems,
+ (owner, ownedItems) => owner.SetMonitoringModeAsync(
context,
monitoringMode,
itemsToModify,
- processedItems,
+ ownedItems,
errors,
- cancellationToken)
- .ConfigureAwait(false);
- }
+ cancellationToken),
+ notifyRetiredGenerationDrain: false,
+ cancellationToken)
+ .ConfigureAwait(false);
// fill results for unknown nodes.
for (int ii = 0; ii < errors.Count; ii++)
@@ -4380,6 +4444,11 @@ private static void SetMonitoringModeForEvents(
{
for (int ii = 0; ii < monitoredItems.Count; ii++)
{
+ if (processedItems[ii])
+ {
+ continue;
+ }
+
// all event subscriptions are handled by the event manager.
if (monitoredItems[ii] is not IEventMonitoredItem monitoredItem ||
(monitoredItem.MonitoredItemType & MonitoredItemTypeMask.Events) == 0)
@@ -4397,6 +4466,188 @@ private static void SetMonitoringModeForEvents(
}
}
+ ///
+ /// Groups the unprocessed data monitored items by their recorded owning
+ /// NodeManager (by reference), preserving the original index of each item. Event
+ /// items and items already handled (processed) or unknown (null) are skipped.
+ ///
+ private static List<(IAsyncNodeManager Owner, List Indices)>?
+ GroupDataMonitoredItemsByOwner(
+ IList monitoredItems,
+ Func isProcessed)
+ {
+ List<(IAsyncNodeManager Owner, List Indices)>? owners = null;
+ for (int ii = 0; ii < monitoredItems.Count; ii++)
+ {
+ if (isProcessed(ii) || monitoredItems[ii] == null)
+ {
+ continue;
+ }
+
+ IAsyncNodeManager owner = monitoredItems[ii].NodeManager;
+ if (owner is null)
+ {
+ continue;
+ }
+
+ owners ??= [];
+ int group = -1;
+ for (int kk = 0; kk < owners.Count; kk++)
+ {
+ if (ReferenceEquals(owners[kk].Owner, owner))
+ {
+ group = kk;
+ break;
+ }
+ }
+
+ if (group < 0)
+ {
+ owners.Add((owner, []));
+ group = owners.Count - 1;
+ }
+
+ owners[group].Indices.Add(ii);
+ }
+
+ return owners;
+ }
+
+ private static ServiceResult? GetRetirementError(IMonitoredItem? monitoredItem)
+ => monitoredItem is IRetirableMonitoredItem { RetirementError: { } error }
+ ? error
+ : null;
+
+ ///
+ /// Dispatches an ownership-sensitive data monitored item operation to each item's
+ /// recorded owning NodeManager rather than to the visible routing-table managers.
+ /// Each owner is offered only the items it owns (all other indices are pre-marked
+ /// processed) so a same-namespace replacement generation can never claim monitored
+ /// items still owned by a shadow-retired generation. Owners that are no longer
+ /// registered in the routing table are shadow-retired generations; when
+ /// is set the registered drain
+ /// observer is notified afterwards so retired generations can be torn down once
+ /// their monitored items drain.
+ ///
+ private async ValueTask DispatchDataMonitoredItemsToOwningNodeManagersAsync(
+ IList monitoredItems,
+ List processedItems,
+ Func, ValueTask> dispatch,
+ bool notifyRetiredGenerationDrain,
+ CancellationToken cancellationToken)
+ {
+ List<(IAsyncNodeManager Owner, List Indices)>? owners =
+ GroupDataMonitoredItemsByOwner(
+ monitoredItems,
+ index => processedItems[index]);
+ if (owners is null)
+ {
+ return;
+ }
+
+ bool retiredGenerationDrained = false;
+ foreach ((IAsyncNodeManager owner, List indices) in owners)
+ {
+ // Present only this owner's items as unprocessed.
+ var ownedItems = new bool[monitoredItems.Count];
+ for (int ii = 0; ii < ownedItems.Length; ii++)
+ {
+ ownedItems[ii] = true;
+ }
+ foreach (int ii in indices)
+ {
+ ownedItems[ii] = false;
+ }
+
+ await dispatch(owner, ownedItems).ConfigureAwait(false);
+
+ // Merge the owner's processed marks back into the shared list.
+ foreach (int ii in indices)
+ {
+ if (ownedItems[ii])
+ {
+ processedItems[ii] = true;
+ }
+ }
+
+ if (notifyRetiredGenerationDrain && !m_nodeManagers.Contains(owner))
+ {
+ retiredGenerationDrained = true;
+ }
+ }
+
+ if (retiredGenerationDrained)
+ {
+ m_retiredGenerationDrainObserver?.Invoke();
+ }
+ }
+
+ ///
+ /// Dispatches Modify to each data monitored item's recorded owning NodeManager.
+ /// Modify tracks per-item completion through
+ /// rather than a processed-flag list, so each owner is isolated by temporarily
+ /// marking every item it does not own as processed for the duration of its call.
+ ///
+ private async ValueTask DispatchModifyToOwningNodeManagersAsync(
+ OperationContext context,
+ TimestampsToReturn timestampsToReturn,
+ IList monitoredItems,
+ ArrayOf itemsToModify,
+ IList errors,
+ IList filterResults,
+ CancellationToken cancellationToken)
+ {
+ List<(IAsyncNodeManager Owner, List Indices)>? owners =
+ GroupDataMonitoredItemsByOwner(
+ monitoredItems,
+ index => itemsToModify[index].Processed);
+ if (owners is null)
+ {
+ return;
+ }
+
+ foreach ((IAsyncNodeManager owner, List indices) in owners)
+ {
+ var ownedItems = new bool[monitoredItems.Count];
+ foreach (int ii in indices)
+ {
+ ownedItems[ii] = true;
+ }
+
+ // Temporarily mark every item this owner does not own as processed so it
+ // only touches its own items, then restore them for the next owner.
+ var masked = new List();
+ for (int ii = 0; ii < monitoredItems.Count; ii++)
+ {
+ if (!ownedItems[ii] && !itemsToModify[ii].Processed)
+ {
+ itemsToModify[ii].Processed = true;
+ masked.Add(ii);
+ }
+ }
+
+ try
+ {
+ await owner.ModifyMonitoredItemsAsync(
+ context,
+ timestampsToReturn,
+ monitoredItems,
+ itemsToModify,
+ errors,
+ filterResults,
+ cancellationToken)
+ .ConfigureAwait(false);
+ }
+ finally
+ {
+ foreach (int ii in masked)
+ {
+ itemsToModify[ii].Processed = false;
+ }
+ }
+ }
+ }
+
private static void ValidatePreparedNodeManager(
PreparedNodeManager prepared,
bool allowPublished = false)
@@ -5469,6 +5720,8 @@ protected internal static ServiceResult ValidateRolePermissions(
private readonly Dictionary>>
m_dynamicExternalReferences = [];
+ private volatile Action? m_retiredGenerationDrainObserver;
+
private bool m_disposed;
}
diff --git a/src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetLifecycleExtensions.cs b/src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetLifecycleExtensions.cs
index 4cfc3c60a0..daa833fd04 100644
--- a/src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetLifecycleExtensions.cs
+++ b/src/Opc.Ua.Server/RuntimeNodeSet/RuntimeNodeSetLifecycleExtensions.cs
@@ -94,5 +94,72 @@ public static ValueTask ReloadRuntimeNodeSetAsync(
new RuntimeNodeSetNodeManagerFactory(replacement),
ct);
}
+
+ ///
+ /// Replaces a live runtime NodeSet registration from replacement options while
+ /// allowing the current generation to keep serving monitored items that were
+ /// already created on it. New service requests are atomically routed to the
+ /// replacement generation as soon as it is committed; the current generation is
+ /// retained only for its existing monitored items and any request or continuation
+ /// point that already captured it, and is disposed automatically once they drain.
+ ///
+ ///
+ /// Thrown when , , or
+ /// is null.
+ ///
+ public static ValueTask ShadowReloadRuntimeNodeSetAsync(
+ this INodeManagerLifecycle lifecycle,
+ NodeManagerRegistration registration,
+ RuntimeNodeSetOptions replacement,
+ CancellationToken ct = default)
+ {
+ if (lifecycle is null)
+ {
+ throw new ArgumentNullException(nameof(lifecycle));
+ }
+ if (registration is null)
+ {
+ throw new ArgumentNullException(nameof(registration));
+ }
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ return lifecycle.ShadowReloadAsync(
+ registration,
+ new RuntimeNodeSetNodeManagerFactory(replacement),
+ ct);
+ }
+
+ ///
+ /// Replaces a live runtime NodeSet registration and immediately invalidates
+ /// monitored items owned by the previous generation with
+ /// .
+ ///
+ public static ValueTask ImmediateReloadRuntimeNodeSetAsync(
+ this INodeManagerLifecycle lifecycle,
+ NodeManagerRegistration registration,
+ RuntimeNodeSetOptions replacement,
+ CancellationToken ct = default)
+ {
+ if (lifecycle is null)
+ {
+ throw new ArgumentNullException(nameof(lifecycle));
+ }
+ if (registration is null)
+ {
+ throw new ArgumentNullException(nameof(registration));
+ }
+ if (replacement is null)
+ {
+ throw new ArgumentNullException(nameof(replacement));
+ }
+
+ return lifecycle.ImmediateReloadAsync(
+ registration,
+ new RuntimeNodeSetNodeManagerFactory(replacement),
+ ct);
+ }
}
}
diff --git a/src/Opc.Ua.Server/Subscription/MonitoredItem/IMonitoredItem.cs b/src/Opc.Ua.Server/Subscription/MonitoredItem/IMonitoredItem.cs
index 3e3d2d8a5c..12b97a99f3 100644
--- a/src/Opc.Ua.Server/Subscription/MonitoredItem/IMonitoredItem.cs
+++ b/src/Opc.Ua.Server/Subscription/MonitoredItem/IMonitoredItem.cs
@@ -151,6 +151,30 @@ public interface IMonitoredItem : IDisposable
MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode);
}
+ ///
+ /// Internal lifecycle contract used to invalidate a monitored item when its
+ /// owning NodeManager generation is retired immediately.
+ ///
+ internal interface IRetirableMonitoredItem
+ {
+ /// Gets whether the item has been retired.
+ bool IsRetired { get; }
+
+ /// Gets the error reported for the retired item.
+ ServiceResult? RetirementError { get; }
+
+ ///
+ /// Marks the item retired and queues its terminal status when the monitored
+ /// item kind supports status values.
+ ///
+ void Retire(ServiceResult error);
+
+ ///
+ /// Releases references to the disposed owner after retirement is final.
+ ///
+ void DetachOwner();
+ }
+
///
/// A monitored item that can be triggered.
///
diff --git a/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs b/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs
index 081c4d9001..0239d85e71 100644
--- a/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs
+++ b/src/Opc.Ua.Server/Subscription/MonitoredItem/MonitoredItem.cs
@@ -41,7 +41,8 @@ namespace Opc.Ua.Server
public class MonitoredItem :
IEventMonitoredItem,
ISampledDataChangeMonitoredItem,
- ITriggeredMonitoredItem
+ ITriggeredMonitoredItem,
+ IRetirableMonitoredItem
{
///
/// Initializes the object with its node type.
@@ -404,6 +405,11 @@ public bool IsReadyToPublish
{
get
{
+ if (m_retirementNotificationPending)
+ {
+ return true;
+ }
+
// check if aggregate interval has passed.
if (m_calculator != null && m_calculator.HasEndTimePassed(DateTime.UtcNow))
{
@@ -481,7 +487,8 @@ public void SetupResendDataTrigger()
{
lock (m_lock)
{
- if (MonitoringMode == MonitoringMode.Reporting &&
+ if (m_retirementError is null &&
+ MonitoringMode == MonitoringMode.Reporting &&
(MonitoredItemType & MonitoredItemTypeMask.DataChange) != 0)
{
m_resendData = true;
@@ -943,6 +950,11 @@ public virtual void QueueValue(in DataValue value, ServiceResult? error, bool ig
{
lock (m_lock)
{
+ if (m_retirementError is not null)
+ {
+ return;
+ }
+
// this method should only be called for variables.
if ((MonitoredItemType & MonitoredItemTypeMask.DataChange) == 0)
{
@@ -1022,6 +1034,91 @@ public virtual void QueueValue(in DataValue value, ServiceResult? error, bool ig
}
}
+ bool IRetirableMonitoredItem.IsRetired
+ {
+ get
+ {
+ lock (m_lock)
+ {
+ return m_retirementError is not null;
+ }
+ }
+ }
+
+ ServiceResult? IRetirableMonitoredItem.RetirementError
+ {
+ get
+ {
+ lock (m_lock)
+ {
+ return m_retirementError;
+ }
+ }
+ }
+
+ void IRetirableMonitoredItem.Retire(ServiceResult error)
+ {
+ if (error is null)
+ {
+ throw new ArgumentNullException(nameof(error));
+ }
+
+ ISubscription? subscription = null;
+ lock (m_lock)
+ {
+ if (m_retirementError is not null)
+ {
+ return;
+ }
+
+ m_retirementError = error;
+ if ((MonitoredItemType & MonitoredItemTypeMask.DataChange) != 0)
+ {
+ m_calculator = null;
+ m_dataChangeQueueHandler?.Dispose();
+ m_dataChangeQueueHandler = null;
+ m_retirementNotificationPending = true;
+ var value = new DataValue(
+ Variant.Null,
+ error.StatusCode,
+ DateTime.UtcNow,
+ DateTime.UtcNow);
+ if (!m_lastValue.IsNull)
+ {
+ m_readyToTrigger = true;
+ }
+ m_lastValue = value;
+ m_lastError = error;
+ m_readyToPublish = true;
+ subscription = m_subscription;
+ }
+ else if ((MonitoredItemType & MonitoredItemTypeMask.Events) != 0)
+ {
+ m_eventQueueHandler?.Dispose();
+ m_eventQueueHandler = null;
+ m_readyToPublish = false;
+ m_readyToTrigger = false;
+ m_triggered = false;
+ }
+ }
+
+ subscription?.ItemReadyToPublish(this);
+ }
+
+ void IRetirableMonitoredItem.DetachOwner()
+ {
+ lock (m_lock)
+ {
+ if (m_retirementError is null)
+ {
+ throw new InvalidOperationException(
+ "A monitored item must be retired before its owner is detached.");
+ }
+ NodeManager = null!;
+ ManagerHandle = null!;
+ }
+ }
+
///
/// Adds a value to the queue.
///
@@ -1122,6 +1219,11 @@ public virtual void QueueEvent(IFilterTarget instance, bool bypassFilter)
lock (m_lock)
{
+ if (m_retirementError is not null)
+ {
+ return;
+ }
+
// this method should only be called for objects or views.
if ((MonitoredItemType & MonitoredItemTypeMask.Events) == 0)
{
@@ -1178,6 +1280,11 @@ public virtual void QueueEvent(EventFieldList fields)
{
lock (m_lock)
{
+ if (m_retirementError is not null)
+ {
+ return;
+ }
+
m_eventQueueHandler!.QueueEvent(fields);
m_readyToPublish = true;
m_readyToTrigger = true;
@@ -1448,7 +1555,9 @@ public virtual bool Publish(
else
{
// pull any unprocessed data.
- if (m_calculator != null && m_calculator.HasEndTimePassed(DateTime.UtcNow))
+ if (!m_retirementNotificationPending &&
+ m_calculator != null &&
+ m_calculator.HasEndTimePassed(DateTime.UtcNow))
{
while (m_calculator.TryGetProcessedValue(false, out DataValue processedValue))
{
@@ -1499,6 +1608,7 @@ public virtual bool Publish(
// reset state variables.
m_readyToPublish = moreValuesToPublish;
m_readyToTrigger = moreValuesToPublish;
+ m_retirementNotificationPending = false;
m_resendData = false;
m_triggered = false;
@@ -2045,6 +2155,8 @@ protected virtual void Dispose(bool disposing)
private bool m_structureChanged;
private ISubscription? m_subscription;
private ServiceResult? m_samplingError;
+ private ServiceResult? m_retirementError;
+ private bool m_retirementNotificationPending;
private IAggregateCalculator? m_calculator;
private bool m_triggered;
private bool m_resendData;
diff --git a/src/Opc.Ua.Server/Subscription/Subscription.cs b/src/Opc.Ua.Server/Subscription/Subscription.cs
index e8b773f485..7974513fa6 100644
--- a/src/Opc.Ua.Server/Subscription/Subscription.cs
+++ b/src/Opc.Ua.Server/Subscription/Subscription.cs
@@ -40,7 +40,10 @@ namespace Opc.Ua.Server
///
/// Manages a subscription created by a client.
///
- public class Subscription : ISubscription, INodeManagerMonitoredItemTracker
+ public class Subscription :
+ ISubscription,
+ INodeManagerMonitoredItemTracker,
+ INodeManagerMonitoredItemRetirementTracker
{
///
/// Initializes the object.
@@ -468,13 +471,127 @@ public bool HasMonitoredItems(IAsyncNodeManager nodeManager)
lock (m_lock)
{
return m_monitoredItems.Values.Any(monitoredItem =>
- ReferenceEquals(monitoredItem.Value.NodeManager, nodeManager) ||
- ReferenceEquals(
- monitoredItem.Value.NodeManager.SyncNodeManager,
- nodeManager.SyncNodeManager));
+ !IsRetired(monitoredItem.Value) &&
+ IsOwnedBy(monitoredItem.Value, nodeManager));
}
}
+ ///
+ bool INodeManagerMonitoredItemRetirementTracker.CanRetireMonitoredItems(
+ IAsyncNodeManager nodeManager)
+ {
+ if (nodeManager is null)
+ {
+ throw new ArgumentNullException(nameof(nodeManager));
+ }
+
+ lock (m_lock)
+ {
+ if (IsDurable)
+ {
+ return false;
+ }
+ return m_monitoredItems.Values
+ .Where(monitoredItem => IsOwnedBy(monitoredItem.Value, nodeManager))
+ .All(monitoredItem =>
+ !monitoredItem.Value.IsDurable &&
+ monitoredItem.Value is IRetirableMonitoredItem);
+ }
+ }
+
+ ///
+ void INodeManagerMonitoredItemRetirementTracker.RetireMonitoredItems(
+ IAsyncNodeManager nodeManager,
+ ServiceResult error)
+ {
+ if (nodeManager is null)
+ {
+ throw new ArgumentNullException(nameof(nodeManager));
+ }
+ if (error is null)
+ {
+ throw new ArgumentNullException(nameof(error));
+ }
+
+ IRetirableMonitoredItem[] ownedItems;
+ lock (m_lock)
+ {
+ if (IsDurable)
+ {
+ throw new NotSupportedException(
+ "Durable subscriptions cannot be retired immediately.");
+ }
+ IMonitoredItem[] candidates = m_monitoredItems.Values
+ .Select(monitoredItem => monitoredItem.Value)
+ .Where(monitoredItem => IsOwnedBy(monitoredItem, nodeManager))
+ .ToArray();
+ if (candidates.Any(monitoredItem =>
+ monitoredItem.IsDurable ||
+ monitoredItem is not IRetirableMonitoredItem))
+ {
+ throw new NotSupportedException(
+ "Durable or unsupported monitored items cannot be retired immediately.");
+ }
+ ownedItems = candidates.Cast().ToArray();
+ }
+
+ foreach (IRetirableMonitoredItem monitoredItem in ownedItems)
+ {
+ monitoredItem.Retire(error);
+ }
+ }
+
+ ///
+ void INodeManagerMonitoredItemRetirementTracker.DetachRetiredMonitoredItems(
+ IAsyncNodeManager nodeManager)
+ {
+ if (nodeManager is null)
+ {
+ throw new ArgumentNullException(nameof(nodeManager));
+ }
+
+ IRetirableMonitoredItem[] retiredItems;
+ lock (m_lock)
+ {
+ retiredItems = m_monitoredItems.Values
+ .Select(monitoredItem => monitoredItem.Value)
+ .Where(monitoredItem =>
+ IsRetired(monitoredItem) &&
+ IsOwnedBy(monitoredItem, nodeManager))
+ .Cast()
+ .ToArray();
+ }
+
+ foreach (IRetirableMonitoredItem monitoredItem in retiredItems)
+ {
+ monitoredItem.DetachOwner();
+ }
+ }
+
+ private static bool IsRetired(IMonitoredItem monitoredItem)
+ => monitoredItem is IRetirableMonitoredItem { IsRetired: true };
+
+ private static bool IsOwnedBy(
+ IMonitoredItem monitoredItem,
+ IAsyncNodeManager nodeManager)
+ {
+ IAsyncNodeManager? monitoredItemOwner = monitoredItem.NodeManager;
+ if (monitoredItemOwner is null)
+ {
+ return false;
+ }
+ if (ReferenceEquals(monitoredItemOwner, nodeManager))
+ {
+ return true;
+ }
+
+ INodeManager? monitoredItemSync = monitoredItemOwner.SyncNodeManager;
+ INodeManager? nodeManagerSync = nodeManager.SyncNodeManager;
+ return monitoredItemSync is not null &&
+ nodeManagerSync is not null &&
+ ReferenceEquals(monitoredItemSync, nodeManagerSync);
+ }
+
///
/// Deletes the subscription.
///
diff --git a/src/Opc.Ua.Server/Subscription/SubscriptionManager.cs b/src/Opc.Ua.Server/Subscription/SubscriptionManager.cs
index 9c2127fc86..5417c9f4ac 100644
--- a/src/Opc.Ua.Server/Subscription/SubscriptionManager.cs
+++ b/src/Opc.Ua.Server/Subscription/SubscriptionManager.cs
@@ -1688,6 +1688,39 @@ public ServiceResult SetSubscriptionDurable(
uint subscriptionId,
uint lifetimeInHours,
out uint revisedLifetimeInHours)
+ {
+ if (m_server.NodeManager is INodeManagerMutationCoordinator coordinator)
+ {
+ (ServiceResult Result, uint Revised) outcome = coordinator
+ .ExecuteMonitoredItemMutationAsync<(ServiceResult Result, uint Revised)>(
+ () =>
+ {
+ ServiceResult result = SetSubscriptionDurableCore(
+ context,
+ subscriptionId,
+ lifetimeInHours,
+ out uint revised);
+ return new ValueTask<(ServiceResult Result, uint Revised)>(
+ (result, revised));
+ },
+ CancellationToken.None)
+ .AsTask().GetAwaiter().GetResult();
+ revisedLifetimeInHours = outcome.Revised;
+ return outcome.Result;
+ }
+
+ return SetSubscriptionDurableCore(
+ context,
+ subscriptionId,
+ lifetimeInHours,
+ out revisedLifetimeInHours);
+ }
+
+ private ServiceResult SetSubscriptionDurableCore(
+ ISystemContext context,
+ uint subscriptionId,
+ uint lifetimeInHours,
+ out uint revisedLifetimeInHours)
{
revisedLifetimeInHours = 0;
diff --git a/src/Opc.Ua.Types/NugetREADME.md b/src/Opc.Ua.Types/NugetREADME.md
index 86e8f4fade..6ab837500c 100644
--- a/src/Opc.Ua.Types/NugetREADME.md
+++ b/src/Opc.Ua.Types/NugetREADME.md
@@ -18,6 +18,35 @@ standard address-space proxies on the client side.
The classes in this package are emitted from the standard XML NodeSet
by the OPC UA source generators (`Opc.Ua.SourceGeneration.Stack`).
+## WoT / NodeSet conversion
+
+The package also exposes the dependency-light `Opc.Ua.Wot`
+conversion surface used by the WoT source generator and WoT Connectivity
+runtime. `WotNodeSetConverter` converts `UANodeSet` models to and from
+Thing Models / Thing Descriptions.
+
+The default output is semantic-first: the converter omits `uav:nodes` when
+the readable vocabulary reconstructs equivalently and adds that complete
+structured projection only for source facts not yet expressible. No
+`uav:nodeSet` envelope is emitted when the structured path is complete.
+Configure
+`WotNodeSetConverterOptions.PreservationMode` as:
+
+* `WhenRequired` (default) — use the envelope only for a demonstrated
+ unsupported/future construct;
+* `Always` — include an explicit byte-exact archival envelope;
+* `Never` — reject any conversion that cannot be proven without the
+ envelope (recommended for conformance tests).
+
+Unmapped WoT JSON-LD members are retained individually as
+pointer-addressed, digest-protected residue in standard NodeSet
+`Extensions`; mapped OPC UA facts are never duplicated there.
+
+Typed-reference links also use NamespaceUri-qualified model names such as
+`ua:HasOrderedComponent` directly in `rel`, alongside a definitive
+`uav:refId` ExpandedNodeId when needed. These names improve model
+semantics without replacing ExpandedNodeIds for instance identity.
+
## Target frameworks
`net472`, `net48`, `netstandard2.0`, `netstandard2.1`, `net8.0`,
diff --git a/src/Opc.Ua.Types/Wot/NodeSetComparer.cs b/src/Opc.Ua.Types/Wot/NodeSetComparer.cs
new file mode 100644
index 0000000000..d42398ab91
--- /dev/null
+++ b/src/Opc.Ua.Types/Wot/NodeSetComparer.cs
@@ -0,0 +1,348 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Text;
+using System.Xml;
+using System.Xml.Linq;
+using Opc.Ua.Export;
+
+namespace Opc.Ua.Wot
+{
+ ///
+ /// The result of comparing two NodeSet2 documents after normalizing
+ /// insignificant XML serialization differences.
+ ///
+ public sealed class NodeSetComparisonResult
+ {
+ internal NodeSetComparisonResult(bool equivalent, IReadOnlyList differences)
+ {
+ AreEquivalent = equivalent;
+ Differences = differences;
+ }
+
+ /// Gets a value indicating whether the documents are semantically equivalent.
+ public bool AreEquivalent { get; }
+
+ /// Gets the human-readable differences, empty when equivalent.
+ public IReadOnlyList Differences { get; }
+ }
+
+ ///
+ /// The result of a NodeSet2 to WoT to NodeSet2 round trip.
+ ///
+ public sealed class NodeSetRoundtripReport
+ {
+ internal NodeSetRoundtripReport(
+ bool nativeProjectionPreserved,
+ bool envelopePreserved,
+ bool usedPreservationEnvelope,
+ NodeSetComparisonResult comparison,
+ IReadOnlyList diagnostics)
+ {
+ NativeProjectionPreserved = nativeProjectionPreserved;
+ EnvelopePreserved = envelopePreserved;
+ UsedPreservationEnvelope = usedPreservationEnvelope;
+ Comparison = comparison;
+ Diagnostics = diagnostics;
+ }
+
+ ///
+ /// Gets a value indicating whether the structured native projection,
+ /// without an envelope, reproduced an equivalent NodeSet2.
+ ///
+ public bool NativeProjectionPreserved { get; }
+
+ ///
+ /// Gets a value indicating whether the envelope reproduced a byte-identical NodeSet2.
+ ///
+ public bool EnvelopePreserved { get; }
+
+ ///
+ /// Gets a value indicating whether the conversion used a
+ /// uav:nodeSet preservation envelope.
+ ///
+ public bool UsedPreservationEnvelope { get; }
+
+ /// Gets the canonical comparison of the source and restored NodeSet2.
+ public NodeSetComparisonResult Comparison { get; }
+
+ /// Gets the diagnostics produced during the round trip.
+ public IReadOnlyList Diagnostics { get; }
+ }
+
+ ///
+ /// Compares NodeSet2 documents on a canonical basis and reports round trips.
+ /// The canonical form ignores indentation, line endings and attribute order
+ /// while preserving element structure, attribute values and text so that
+ /// semantic changes are detected.
+ ///
+ public static class NodeSetComparer
+ {
+ ///
+ /// Compares two NodeSet2 documents on a canonical basis.
+ ///
+ /// The first document.
+ /// The second document.
+ /// The comparison result.
+ public static NodeSetComparisonResult Compare(UANodeSet left, UANodeSet right)
+ {
+ if (left is null)
+ {
+ throw new ArgumentNullException(nameof(left));
+ }
+ if (right is null)
+ {
+ throw new ArgumentNullException(nameof(right));
+ }
+ return CompareXml(Serialize(left), Serialize(right));
+ }
+
+ ///
+ /// Compares two serialized NodeSet2 documents on a canonical basis.
+ ///
+ /// The first serialized document.
+ /// The second serialized document.
+ /// The comparison result.
+ public static NodeSetComparisonResult CompareXml(byte[] left, byte[] right)
+ {
+ if (left is null)
+ {
+ throw new ArgumentNullException(nameof(left));
+ }
+ if (right is null)
+ {
+ throw new ArgumentNullException(nameof(right));
+ }
+ string canonicalLeft = Canonicalize(Encoding.UTF8.GetString(StripPreamble(left)));
+ string canonicalRight = Canonicalize(Encoding.UTF8.GetString(StripPreamble(right)));
+ return BuildResult(canonicalLeft, canonicalRight);
+ }
+
+ ///
+ /// Converts a NodeSet2 document to a WoT document and back. By default,
+ /// the report uses native-only mode so completeness is never proved by
+ /// the preservation envelope.
+ ///
+ /// The NodeSet2 document to round trip.
+ /// Resource limits; defaults are used when omitted.
+ /// The round-trip report.
+ public static NodeSetRoundtripReport Roundtrip(
+ UANodeSet source,
+ WotNodeSetConverterOptions? options = null)
+ {
+ if (source is null)
+ {
+ throw new ArgumentNullException(nameof(source));
+ }
+
+ var diagnostics = new List();
+ byte[] sourceBytes = Serialize(source);
+ WotNodeSetConverterOptions effectiveOptions = options ??
+ new WotNodeSetConverterOptions
+ {
+ PreservationMode = WotNodeSetPreservationMode.Never
+ };
+
+ WotConversionResult forward =
+ WotNodeSetConverter.FromNodeSetResult(source, null, effectiveOptions);
+ AddRange(diagnostics, forward.Diagnostics);
+ if (forward.Value is null)
+ {
+ return new NodeSetRoundtripReport(
+ false,
+ false,
+ false,
+ new NodeSetComparisonResult(false, ["The NodeSet could not be converted to a WoT document."]),
+ diagnostics);
+ }
+
+ using WotDocument document = forward.Value;
+ bool usedEnvelope = document.TryGetEnvelope(out _);
+ WotConversionResult backward =
+ WotNodeSetConverter.ToNodeSetResult(document, effectiveOptions);
+ AddRange(diagnostics, backward.Diagnostics);
+ if (backward.Value is null)
+ {
+ return new NodeSetRoundtripReport(
+ false,
+ false,
+ usedEnvelope,
+ new NodeSetComparisonResult(false, ["The WoT document could not be converted back to a NodeSet."]),
+ diagnostics);
+ }
+
+ byte[] restoredBytes = Serialize(backward.Value);
+ NodeSetComparisonResult comparison = CompareXml(sourceBytes, restoredBytes);
+ bool byteIdentical = ByteEquals(sourceBytes, restoredBytes);
+ return new NodeSetRoundtripReport(
+ !usedEnvelope && comparison.AreEquivalent,
+ usedEnvelope && byteIdentical,
+ usedEnvelope,
+ comparison,
+ diagnostics);
+ }
+
+ private static NodeSetComparisonResult BuildResult(string left, string right)
+ {
+ if (string.Equals(left, right, StringComparison.Ordinal))
+ {
+ return new NodeSetComparisonResult(true, []);
+ }
+ return new NodeSetComparisonResult(false, [DescribeDifference(left, right)]);
+ }
+
+ private static string DescribeDifference(string left, string right)
+ {
+ int limit = Math.Min(left.Length, right.Length);
+ int index = 0;
+ while (index < limit && left[index] == right[index])
+ {
+ index++;
+ }
+ int start = Math.Max(0, index - 24);
+ string leftContext = Excerpt(left, start, index);
+ string rightContext = Excerpt(right, start, index);
+ return string.Format(
+ System.Globalization.CultureInfo.InvariantCulture,
+ "Canonical NodeSet documents differ at position {0}: '{1}' vs '{2}'.",
+ index,
+ leftContext,
+ rightContext);
+ }
+
+ private static string Excerpt(string text, int start, int index)
+ {
+ int end = Math.Min(text.Length, index + 24);
+ return text.Substring(start, end - start);
+ }
+
+ private static string Canonicalize(string xml)
+ {
+ var settings = new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ IgnoreWhitespace = true,
+ IgnoreComments = true,
+ IgnoreProcessingInstructions = true
+ };
+ XDocument document;
+ using (var stringReader = new StringReader(xml))
+ using (XmlReader reader = XmlReader.Create(stringReader, settings))
+ {
+ document = XDocument.Load(reader);
+ }
+ var builder = new StringBuilder();
+ if (document.Root is not null)
+ {
+ WriteElement(builder, document.Root);
+ }
+ return builder.ToString();
+ }
+
+ private static void WriteElement(StringBuilder builder, XElement element)
+ {
+ builder.Append('<').Append(element.Name.ToString());
+
+ var attributes = new List(element.Attributes());
+ attributes.Sort(static (left, right) =>
+ string.CompareOrdinal(left.Name.ToString(), right.Name.ToString()));
+ foreach (XAttribute attribute in attributes)
+ {
+ builder.Append(' ')
+ .Append(attribute.Name.ToString())
+ .Append("=\"")
+ .Append(attribute.Value)
+ .Append('"');
+ }
+ builder.Append('>');
+
+ foreach (XNode node in element.Nodes())
+ {
+ switch (node)
+ {
+ case XElement child:
+ WriteElement(builder, child);
+ break;
+ case XText text:
+ builder.Append(text.Value);
+ break;
+ }
+ }
+
+ builder.Append("").Append(element.Name.ToString()).Append('>');
+ }
+
+ private static byte[] Serialize(UANodeSet nodeSet)
+ {
+ using var stream = new MemoryStream();
+ nodeSet.Write(stream);
+ return stream.ToArray();
+ }
+
+ private static byte[] StripPreamble(byte[] bytes)
+ {
+ if (bytes.Length >= 3 && bytes[0] == 0xEF && bytes[1] == 0xBB && bytes[2] == 0xBF)
+ {
+ var trimmed = new byte[bytes.Length - 3];
+ Array.Copy(bytes, 3, trimmed, 0, trimmed.Length);
+ return trimmed;
+ }
+ return bytes;
+ }
+
+ private static bool ByteEquals(byte[] left, byte[] right)
+ {
+ if (left.Length != right.Length)
+ {
+ return false;
+ }
+ for (int ii = 0; ii < left.Length; ii++)
+ {
+ if (left[ii] != right[ii])
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
+ private static void AddRange(List target, IReadOnlyList source)
+ {
+ for (int ii = 0; ii < source.Count; ii++)
+ {
+ target.Add(source[ii]);
+ }
+ }
+ }
+}
diff --git a/src/Opc.Ua.Types/Wot/WotDiagnostics.cs b/src/Opc.Ua.Types/Wot/WotDiagnostics.cs
new file mode 100644
index 0000000000..192ea8838b
--- /dev/null
+++ b/src/Opc.Ua.Types/Wot/WotDiagnostics.cs
@@ -0,0 +1,360 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Text;
+
+namespace Opc.Ua.Wot
+{
+ ///
+ /// Severity of a .
+ ///
+ public enum WotDiagnosticSeverity
+ {
+ /// An informational note; conversion succeeded.
+ Info,
+
+ /// A recoverable concern; conversion succeeded with caveats.
+ Warning,
+
+ /// A fatal problem; the associated conversion did not succeed.
+ Error
+ }
+
+ ///
+ /// Stable diagnostic codes emitted by the WoT/NodeSet conversion.
+ ///
+ public enum WotDiagnosticCode
+ {
+ /// No specific code.
+ None = 0,
+
+ /// The JSON document exceeded the configured byte limit.
+ JsonDocumentTooLarge = 1000,
+
+ /// The NodeSet2 payload exceeded the configured byte limit.
+ NodeSetTooLarge = 1001,
+
+ /// A nesting depth limit was exceeded.
+ DepthExceeded = 1002,
+
+ /// The node count limit was exceeded.
+ NodeCountExceeded = 1003,
+
+ /// The affordance count limit was exceeded.
+ AffordanceCountExceeded = 1004,
+
+ /// The JSON document was malformed.
+ MalformedJson = 1005,
+
+ /// The NodeSet2 XML was malformed.
+ MalformedNodeSet = 1006,
+
+ /// The preservation envelope is missing.
+ EnvelopeMissing = 2000,
+
+ /// The preservation envelope is structurally invalid.
+ EnvelopeInvalid = 2001,
+
+ /// The envelope content type is not supported.
+ UnsupportedContentType = 2002,
+
+ /// The envelope encoding is not supported.
+ UnsupportedEncoding = 2003,
+
+ /// The envelope data was not valid base64.
+ InvalidBase64 = 2004,
+
+ /// The envelope digest was not a valid SHA-256 value.
+ InvalidDigest = 2005,
+
+ /// The envelope digest did not match the decoded payload.
+ DigestMismatch = 2006,
+
+ /// A native member restated a baseline fact inconsistently.
+ NativeProjectionConflict = 3000,
+
+ /// Neither an envelope nor a native projection was present.
+ NoConvertibleContent = 3001,
+
+ /// A native projection record was structurally invalid.
+ NativeProjectionInvalid = 3002,
+
+ ///
+ /// The structured native projection could not reproduce the source
+ /// NodeSet and required an explicit preservation-envelope fallback.
+ ///
+ NativeProjectionIncomplete = 3003,
+
+ ///
+ /// Pointer-addressed WoT JSON residue in a NodeSet Extension was invalid.
+ ///
+ ResidueInvalid = 3004,
+
+ ///
+ /// Preserved WoT JSON residue conflicted with a value reconstructed from
+ /// OPC UA model facts.
+ ///
+ ResidueConflict = 3005,
+
+ /// A referenced target could not be resolved to a NodeId.
+ UnresolvedReference = 4000,
+
+ /// A NodeId was generated deterministically because none was supplied.
+ GeneratedNodeId = 4001,
+
+ /// A WoT construct had no faithful NodeSet2 representation.
+ LossySynthesis = 4002,
+
+ /// A required BrowseName or title was missing.
+ MissingBrowseName = 4003,
+
+ /// A DataSchema could not be mapped to an OPC UA DataType.
+ UnsupportedSchema = 4004,
+
+ /// External resolution detected a cycle.
+ ResolverCycle = 5000,
+
+ /// External resolution exceeded the configured depth.
+ ResolverDepthExceeded = 5001,
+
+ /// External resolution exceeded a configured resource limit.
+ ResolverLimitExceeded = 5002,
+
+ /// An external document could not be resolved.
+ ResolverNotFound = 5003,
+
+ /// A document validation rule was violated.
+ ValidationError = 6000,
+
+ ///
+ /// A portable identity term used the session-local ns=<index>
+ /// form instead of an OPC 10000-6 ExpandedNodeId (WoT Binding Section 5.1.1).
+ ///
+ NonPortableIdentity = 6001,
+
+ ///
+ /// An event affordance annotated @type: uav:eventType also set
+ /// uav:isEvent: false, contradicting the event mapping
+ /// (WoT Binding Section 5.2).
+ ///
+ EventAnnotationConflict = 6002,
+
+ ///
+ /// A NamespaceUri-qualified model-name hint could not be resolved and
+ /// no definitive ExpandedNodeId fallback was available.
+ ///
+ ModelConceptUnresolved = 6003,
+
+ ///
+ /// A model-name hint and its definitive ExpandedNodeId resolved to
+ /// different OPC UA model Nodes.
+ ///
+ ModelConceptConflict = 6004,
+
+ ///
+ /// A readable QualifiedName or BrowsePath persisted a numeric namespace
+ /// index instead of a NamespaceUri-qualified form.
+ ///
+ NonPortableQualifiedName = 6005
+ }
+
+ ///
+ /// Locates a diagnostic within a WoT document and/or a NodeSet2 document.
+ ///
+ public sealed class WotLocation
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// An RFC 6901 JSON Pointer into the WoT document.
+ /// An OPC UA NodeId string.
+ /// An OPC UA attribute name.
+ /// A reference descriptor (type and target).
+ public WotLocation(
+ string? jsonPointer = null,
+ string? nodeId = null,
+ string? attribute = null,
+ string? reference = null)
+ {
+ JsonPointer = jsonPointer;
+ NodeId = nodeId;
+ Attribute = attribute;
+ Reference = reference;
+ }
+
+ /// Gets the RFC 6901 JSON Pointer of the location, if any.
+ public string? JsonPointer { get; }
+
+ /// Gets the OPC UA NodeId of the location, if any.
+ public string? NodeId { get; }
+
+ /// Gets the OPC UA attribute name of the location, if any.
+ public string? Attribute { get; }
+
+ /// Gets the reference descriptor of the location, if any.
+ public string? Reference { get; }
+
+ /// Creates a location from a JSON Pointer.
+ public static WotLocation FromPointer(string jsonPointer)
+ {
+ return new WotLocation(jsonPointer: jsonPointer);
+ }
+
+ /// Creates a location from a NodeId and optional attribute.
+ public static WotLocation FromNode(string nodeId, string? attribute = null)
+ {
+ return new WotLocation(nodeId: nodeId, attribute: attribute);
+ }
+
+ ///
+ public override string ToString()
+ {
+ var builder = new StringBuilder();
+ Append(builder, nameof(JsonPointer), JsonPointer);
+ Append(builder, nameof(NodeId), NodeId);
+ Append(builder, nameof(Attribute), Attribute);
+ Append(builder, nameof(Reference), Reference);
+ return builder.Length == 0 ? "(document)" : builder.ToString();
+
+ static void Append(StringBuilder builder, string name, string? value)
+ {
+ if (string.IsNullOrEmpty(value))
+ {
+ return;
+ }
+ if (builder.Length > 0)
+ {
+ builder.Append(", ");
+ }
+ builder.Append(name).Append('=').Append(value);
+ }
+ }
+ }
+
+ ///
+ /// A single structured conversion diagnostic.
+ ///
+ public sealed class WotDiagnostic
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The severity of the diagnostic.
+ /// The stable diagnostic code.
+ /// A human-readable message.
+ /// The optional location of the diagnostic.
+ public WotDiagnostic(
+ WotDiagnosticSeverity severity,
+ WotDiagnosticCode code,
+ string message,
+ WotLocation? location = null)
+ {
+ Severity = severity;
+ Code = code;
+ Message = message ?? throw new ArgumentNullException(nameof(message));
+ Location = location;
+ }
+
+ /// Gets the severity of the diagnostic.
+ public WotDiagnosticSeverity Severity { get; }
+
+ /// Gets the stable diagnostic code.
+ public WotDiagnosticCode Code { get; }
+
+ /// Gets the human-readable message.
+ public string Message { get; }
+
+ /// Gets the optional location of the diagnostic.
+ public WotLocation? Location { get; }
+
+ ///
+ public override string ToString()
+ {
+ return string.Format(
+ CultureInfo.InvariantCulture,
+ "{0} WOT{1:D4}: {2}{3}",
+ Severity,
+ (int)Code,
+ Message,
+ Location is null ? string.Empty : " [" + Location + "]");
+ }
+ }
+
+ ///
+ /// The outcome of a WoT/NodeSet conversion: an optional value together
+ /// with the structured diagnostics that describe how it was produced.
+ ///
+ /// The type of the produced value.
+ public sealed class WotConversionResult
+ where T : class
+ {
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The produced value, or null on failure.
+ /// The diagnostics produced.
+ public WotConversionResult(T? value, IReadOnlyList diagnostics)
+ {
+ Value = value;
+ Diagnostics = diagnostics ?? throw new ArgumentNullException(nameof(diagnostics));
+ }
+
+ /// Gets the produced value, or null when conversion failed.
+ public T? Value { get; }
+
+ /// Gets the diagnostics produced during conversion.
+ public IReadOnlyList Diagnostics { get; }
+
+ /// Gets a value indicating whether any error diagnostic was produced.
+ public bool HasErrors
+ {
+ get
+ {
+ for (int ii = 0; ii < Diagnostics.Count; ii++)
+ {
+ if (Diagnostics[ii].Severity == WotDiagnosticSeverity.Error)
+ {
+ return true;
+ }
+ }
+ return false;
+ }
+ }
+
+ ///
+ /// Gets a value indicating whether conversion produced a usable value
+ /// without any error diagnostic.
+ ///
+ public bool Success => Value is not null && !HasErrors;
+ }
+}
diff --git a/src/Opc.Ua.Types/Wot/WotDocument.cs b/src/Opc.Ua.Types/Wot/WotDocument.cs
new file mode 100644
index 0000000000..d76e7ccca9
--- /dev/null
+++ b/src/Opc.Ua.Types/Wot/WotDocument.cs
@@ -0,0 +1,630 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text;
+using System.Text.Json;
+
+namespace Opc.Ua.Wot
+{
+ ///
+ /// The kind of WoT document, derived from its @type.
+ ///
+ public enum WotDocumentKind
+ {
+ /// The document kind could not be determined.
+ Unknown,
+
+ /// A class-level Thing Model (an OPC UA type projection).
+ ThingModel,
+
+ /// An instance-level Thing Description (an OPC UA Object projection).
+ ThingDescription
+ }
+
+ ///
+ /// A losslessly retained Web of Things JSON document with a lexical
+ /// access surface over the W3C Thing Description / Thing Model model.
+ ///
+ ///
+ /// The original UTF-8 representation is retained so unknown JSON-LD terms
+ /// can be written back byte-for-byte without a lossy object-model
+ /// projection. Typed access is exposed over the parsed
+ /// tree rather than one POCO per
+ /// W3C class, so members the binding does not model are still reachable and
+ /// preserved. A separate deterministic canonical writer is provided.
+ ///
+ public sealed class WotDocument : IDisposable
+ {
+ /// The JSON-LD prefix bound to the OPC UA WoT Binding namespace.
+ public const string UavPrefix = "uav:";
+
+ private WotDocument(byte[] utf8Json, JsonDocument document)
+ {
+ m_utf8Json = utf8Json;
+ m_document = document;
+ }
+
+ ///
+ /// Gets the parsed document root.
+ ///
+ public JsonElement RootElement => m_document.RootElement;
+
+ ///
+ /// Gets the original UTF-8 document bytes.
+ ///
+ public ReadOnlyMemory Utf8Json => m_utf8Json;
+
+ ///
+ /// Gets the document kind derived from @type.
+ ///
+ public WotDocumentKind Kind
+ {
+ get
+ {
+ foreach (string token in TypeTokens)
+ {
+ if (string.Equals(token, "tm:ThingModel", StringComparison.Ordinal) ||
+ string.Equals(token, UavPrefix + "objectType", StringComparison.Ordinal) ||
+ string.Equals(token, UavPrefix + "variableType", StringComparison.Ordinal))
+ {
+ return WotDocumentKind.ThingModel;
+ }
+ }
+ foreach (string token in TypeTokens)
+ {
+ if (string.Equals(token, UavPrefix + "object", StringComparison.Ordinal) ||
+ string.Equals(token, UavPrefix + "variable", StringComparison.Ordinal) ||
+ string.Equals(token, UavPrefix + "method", StringComparison.Ordinal))
+ {
+ return WotDocumentKind.ThingDescription;
+ }
+ }
+ return WotDocumentKind.Unknown;
+ }
+ }
+
+ ///
+ /// Gets the @type tokens of the document.
+ ///
+ public IReadOnlyList TypeTokens
+ {
+ get
+ {
+ m_typeTokens ??= ReadStringTokens("@type");
+ return m_typeTokens;
+ }
+ }
+
+ /// Gets the document title, if present.
+ public string? Title => GetRootString("title");
+
+ /// Gets the document id, if present.
+ public string? Id => GetRootString("id");
+
+ ///
+ /// Attempts to get the @context element.
+ ///
+ /// The context element on success.
+ /// true when a @context member is present.
+ public bool TryGetContext(out JsonElement context)
+ {
+ return TryGetRootProperty("@context", out context);
+ }
+
+ /// Gets the properties affordance map (name to schema).
+ public IReadOnlyDictionary Properties
+ {
+ get
+ {
+ m_properties ??= ReadObjectMap("properties");
+ return m_properties;
+ }
+ }
+
+ /// Gets the actions affordance map (name to affordance).
+ public IReadOnlyDictionary Actions
+ {
+ get
+ {
+ m_actions ??= ReadObjectMap("actions");
+ return m_actions;
+ }
+ }
+
+ /// Gets the events affordance map (name to affordance).
+ public IReadOnlyDictionary Events
+ {
+ get
+ {
+ m_events ??= ReadObjectMap("events");
+ return m_events;
+ }
+ }
+
+ /// Gets the securityDefinitions map, if present.
+ public IReadOnlyDictionary SecurityDefinitions
+ {
+ get
+ {
+ m_securityDefinitions ??= ReadObjectMap("securityDefinitions");
+ return m_securityDefinitions;
+ }
+ }
+
+ /// Gets the schemaDefinitions map, if present.
+ public IReadOnlyDictionary SchemaDefinitions
+ {
+ get
+ {
+ m_schemaDefinitions ??= ReadObjectMap("schemaDefinitions");
+ return m_schemaDefinitions;
+ }
+ }
+
+ /// Gets the top-level links array elements.
+ public IReadOnlyList Links
+ {
+ get
+ {
+ m_links ??= ReadArray("links");
+ return m_links;
+ }
+ }
+
+ /// Gets the top-level forms array elements.
+ public IReadOnlyList Forms
+ {
+ get
+ {
+ m_forms ??= ReadArray("forms");
+ return m_forms;
+ }
+ }
+
+ ///
+ /// Attempts to get the uav:nodeSet preservation envelope.
+ ///
+ /// The envelope element on success.
+ /// true when the envelope is present as an object.
+ public bool TryGetEnvelope(out JsonElement envelope)
+ {
+ return TryGetUav("nodeSet", out envelope) &&
+ envelope.ValueKind == JsonValueKind.Object;
+ }
+
+ ///
+ /// Attempts to get the native uav:nodes projection.
+ ///
+ /// The projection element on success.
+ /// true when the projection is present as an object.
+ public bool TryGetNativeProjection(out JsonElement projection)
+ {
+ return TryGetUav("nodes", out projection) &&
+ projection.ValueKind == JsonValueKind.Object;
+ }
+
+ ///
+ /// Attempts to get a uav:-prefixed member of the document root.
+ ///
+ /// The local term name without the prefix.
+ /// The member value on success.
+ /// true when the member is present.
+ public bool TryGetUav(string localName, out JsonElement value)
+ {
+ if (localName is null)
+ {
+ throw new ArgumentNullException(nameof(localName));
+ }
+ return TryGetRootProperty(UavPrefix + localName, out value);
+ }
+
+ ///
+ /// Evaluates an RFC 6901 JSON Pointer against the document root.
+ ///
+ /// The JSON Pointer (empty string addresses the root).
+ /// The addressed element on success.
+ /// true when the pointer resolves.
+ public bool TryEvaluatePointer(string pointer, out JsonElement value)
+ {
+ if (pointer is null)
+ {
+ throw new ArgumentNullException(nameof(pointer));
+ }
+ return TryEvaluatePointer(RootElement, pointer, out value);
+ }
+
+ ///
+ /// Evaluates an RFC 6901 JSON Pointer against a given element.
+ ///
+ /// The element to evaluate the pointer against.
+ /// The JSON Pointer (empty string addresses ).
+ /// The addressed element on success.
+ /// true when the pointer resolves.
+ public static bool TryEvaluatePointer(JsonElement root, string pointer, out JsonElement value)
+ {
+ if (pointer is null)
+ {
+ throw new ArgumentNullException(nameof(pointer));
+ }
+
+ value = root;
+ if (pointer.Length == 0)
+ {
+ return true;
+ }
+ if (pointer[0] != '/')
+ {
+ value = default;
+ return false;
+ }
+
+ JsonElement current = root;
+ int index = 1;
+ while (index <= pointer.Length)
+ {
+ int next = pointer.IndexOf('/', index);
+ if (next < 0)
+ {
+ next = pointer.Length;
+ }
+ string token = UnescapePointerToken(pointer.Substring(index, next - index));
+ index = next + 1;
+
+ switch (current.ValueKind)
+ {
+ case JsonValueKind.Object:
+ if (!current.TryGetProperty(token, out current))
+ {
+ value = default;
+ return false;
+ }
+ break;
+ case JsonValueKind.Array:
+ if (!TryGetArrayElement(current, token, out current))
+ {
+ value = default;
+ return false;
+ }
+ break;
+ default:
+ value = default;
+ return false;
+ }
+ }
+
+ value = current;
+ return true;
+ }
+
+ ///
+ /// Parses a UTF-8 WoT document while preserving its original bytes.
+ ///
+ /// The UTF-8 encoded document.
+ /// Resource limits; defaults are used when omitted.
+ /// The parsed, byte-preserving document.
+ /// Thrown when the document exceeds the configured byte limit.
+ public static WotDocument Parse(
+ ReadOnlyMemory utf8Json,
+ WotNodeSetConverterOptions? options = null)
+ {
+ options ??= new WotNodeSetConverterOptions();
+ options.Validate();
+ if (utf8Json.Length > options.MaxJsonDocumentSize)
+ {
+ throw new FormatException(
+ $"WoT document exceeds the configured {options.MaxJsonDocumentSize} byte limit.");
+ }
+
+ byte[] copy = utf8Json.ToArray();
+ JsonDocument document = JsonDocument.Parse(
+ copy,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = options.MaxJsonDepth
+ });
+ return new WotDocument(copy, document);
+ }
+
+ ///
+ /// Writes the original UTF-8 document bytes to .
+ ///
+ /// The destination stream.
+ public void Write(Stream stream)
+ {
+ if (stream is null)
+ {
+ throw new ArgumentNullException(nameof(stream));
+ }
+ stream.Write(m_utf8Json, 0, m_utf8Json.Length);
+ }
+
+ ///
+ /// Writes a deterministic canonical serialization of the document to
+ /// . Object members are ordered by name and
+ /// insignificant whitespace is removed so equivalent documents produce
+ /// byte-identical output. Unlike this does
+ /// not preserve the original byte layout.
+ ///
+ /// The destination stream.
+ public void WriteCanonical(Stream stream)
+ {
+ if (stream is null)
+ {
+ throw new ArgumentNullException(nameof(stream));
+ }
+ using var writer = new Utf8JsonWriter(
+ stream,
+ new JsonWriterOptions { Indented = false, SkipValidation = false });
+ WriteCanonical(writer, RootElement);
+ writer.Flush();
+ }
+
+ ///
+ /// Returns the deterministic canonical serialization of the document.
+ ///
+ /// The canonical UTF-8 bytes.
+ public byte[] ToCanonicalUtf8()
+ {
+ using var stream = new MemoryStream();
+ WriteCanonical(stream);
+ return stream.ToArray();
+ }
+
+ ///
+ public void Dispose()
+ {
+ m_document.Dispose();
+ }
+
+ internal static WotDocument FromOwnedBytes(
+ byte[] utf8Json,
+ WotNodeSetConverterOptions options)
+ {
+ JsonDocument document = JsonDocument.Parse(
+ utf8Json,
+ new JsonDocumentOptions
+ {
+ AllowTrailingCommas = false,
+ CommentHandling = JsonCommentHandling.Disallow,
+ MaxDepth = options.MaxJsonDepth
+ });
+ return new WotDocument(utf8Json, document);
+ }
+
+ private static void WriteCanonical(Utf8JsonWriter writer, JsonElement element)
+ {
+ switch (element.ValueKind)
+ {
+ case JsonValueKind.Object:
+ writer.WriteStartObject();
+ var members = new List>();
+ foreach (JsonProperty property in element.EnumerateObject())
+ {
+ members.Add(new KeyValuePair(property.Name, property.Value));
+ }
+ members.Sort(static (left, right) =>
+ string.CompareOrdinal(left.Key, right.Key));
+ foreach (KeyValuePair member in members)
+ {
+ writer.WritePropertyName(member.Key);
+ WriteCanonical(writer, member.Value);
+ }
+ writer.WriteEndObject();
+ break;
+ case JsonValueKind.Array:
+ writer.WriteStartArray();
+ foreach (JsonElement item in element.EnumerateArray())
+ {
+ WriteCanonical(writer, item);
+ }
+ writer.WriteEndArray();
+ break;
+ case JsonValueKind.String:
+ writer.WriteStringValue(element.GetString());
+ break;
+ case JsonValueKind.Number:
+ writer.WriteRawValue(element.GetRawText(), skipInputValidation: true);
+ break;
+ case JsonValueKind.True:
+ case JsonValueKind.False:
+ writer.WriteBooleanValue(element.GetBoolean());
+ break;
+ case JsonValueKind.Null:
+ writer.WriteNullValue();
+ break;
+ default:
+ writer.WriteNullValue();
+ break;
+ }
+ }
+
+ private static bool TryGetArrayElement(JsonElement array, string token, out JsonElement value)
+ {
+ value = default;
+ if (token.Length == 0 ||
+ (token.Length > 1 && token[0] == '0'))
+ {
+ return false;
+ }
+ if (!int.TryParse(
+ token,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out int arrayIndex))
+ {
+ return false;
+ }
+ if (arrayIndex < 0 || arrayIndex >= array.GetArrayLength())
+ {
+ return false;
+ }
+ value = array[arrayIndex];
+ return true;
+ }
+
+ private static string UnescapePointerToken(string token)
+ {
+ int tilde = -1;
+ for (int ii = 0; ii < token.Length; ii++)
+ {
+ if (token[ii] == '~')
+ {
+ tilde = ii;
+ break;
+ }
+ }
+ if (tilde < 0)
+ {
+ return token;
+ }
+
+ var builder = new StringBuilder(token.Length);
+ builder.Append(token, 0, tilde);
+ for (int ii = tilde; ii < token.Length; ii++)
+ {
+ char current = token[ii];
+ if (current == '~' && ii + 1 < token.Length)
+ {
+ char next = token[ii + 1];
+ if (next == '1')
+ {
+ builder.Append('/');
+ ii++;
+ continue;
+ }
+ if (next == '0')
+ {
+ builder.Append('~');
+ ii++;
+ continue;
+ }
+ }
+ builder.Append(current);
+ }
+ return builder.ToString();
+ }
+
+ private bool TryGetRootProperty(string name, out JsonElement value)
+ {
+ JsonElement root = RootElement;
+ if (root.ValueKind == JsonValueKind.Object &&
+ root.TryGetProperty(name, out value))
+ {
+ return true;
+ }
+ value = default;
+ return false;
+ }
+
+ private string? GetRootString(string name)
+ {
+ return TryGetRootProperty(name, out JsonElement value) &&
+ value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+ }
+
+ private List ReadStringTokens(string name)
+ {
+ var tokens = new List();
+ if (TryGetRootProperty(name, out JsonElement value))
+ {
+ if (value.ValueKind == JsonValueKind.String)
+ {
+ string? token = value.GetString();
+ if (token is not null)
+ {
+ tokens.Add(token);
+ }
+ }
+ else if (value.ValueKind == JsonValueKind.Array)
+ {
+ foreach (JsonElement item in value.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.String)
+ {
+ string? token = item.GetString();
+ if (token is not null)
+ {
+ tokens.Add(token);
+ }
+ }
+ }
+ }
+ }
+ return tokens;
+ }
+
+ private Dictionary ReadObjectMap(string name)
+ {
+ var map = new Dictionary(StringComparer.Ordinal);
+ if (TryGetRootProperty(name, out JsonElement value) &&
+ value.ValueKind == JsonValueKind.Object)
+ {
+ foreach (JsonProperty property in value.EnumerateObject())
+ {
+ map[property.Name] = property.Value;
+ }
+ }
+ return map;
+ }
+
+ private List ReadArray(string name)
+ {
+ var items = new List();
+ if (TryGetRootProperty(name, out JsonElement value) &&
+ value.ValueKind == JsonValueKind.Array)
+ {
+ foreach (JsonElement item in value.EnumerateArray())
+ {
+ items.Add(item);
+ }
+ }
+ return items;
+ }
+
+ private readonly byte[] m_utf8Json;
+ private readonly JsonDocument m_document;
+ private IReadOnlyList? m_typeTokens;
+ private IReadOnlyDictionary? m_properties;
+ private IReadOnlyDictionary? m_actions;
+ private IReadOnlyDictionary? m_events;
+ private IReadOnlyDictionary? m_securityDefinitions;
+ private IReadOnlyDictionary? m_schemaDefinitions;
+ private IReadOnlyList? m_links;
+ private IReadOnlyList? m_forms;
+ }
+}
diff --git a/src/Opc.Ua.Types/Wot/WotJsonResidue.cs b/src/Opc.Ua.Types/Wot/WotJsonResidue.cs
new file mode 100644
index 0000000000..de7a675ccb
--- /dev/null
+++ b/src/Opc.Ua.Types/Wot/WotJsonResidue.cs
@@ -0,0 +1,1109 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Security.Cryptography;
+using System.Text;
+using System.Text.Json;
+using System.Text.Json.Nodes;
+using System.Xml;
+using Opc.Ua.Export;
+
+namespace Opc.Ua.Wot
+{
+ ///
+ /// Preserves only WoT members that have no OPC UA model representation as
+ /// pointer-addressed JSON values in a standard NodeSet Extension.
+ ///
+ internal static class WotJsonResidue
+ {
+ private const string ResidueElement = "WoTJsonResidue";
+ private const string MemberElement = "Member";
+ private const string Version = "1.0";
+
+ private sealed class Entry
+ {
+ public required string Pointer { get; init; }
+
+ public required string Json { get; init; }
+
+ public string? LinkRel { get; init; }
+
+ public string? LinkHref { get; init; }
+
+ public string? LinkRefId { get; init; }
+
+ public string? LinkRefName { get; init; }
+ }
+
+ public static void Replace(
+ UANodeSet nodeSet,
+ WotDocument document,
+ WotNodeSetConverterOptions options,
+ List diagnostics)
+ {
+ List entries = Capture(document.RootElement);
+ var extensions = new List();
+ if (nodeSet.Extensions is not null)
+ {
+ foreach (System.Xml.XmlElement extension in nodeSet.Extensions)
+ {
+ if (!IsResidue(extension))
+ {
+ extensions.Add(extension);
+ }
+ }
+ }
+
+ if (entries.Count > 0)
+ {
+ int total = 0;
+ foreach (Entry entry in entries)
+ {
+ total += Encoding.UTF8.GetByteCount(entry.Json);
+ if (total > options.MaxJsonDocumentSize)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.JsonDocumentTooLarge,
+ $"Unmapped WoT residue exceeds the configured " +
+ $"{options.MaxJsonDocumentSize} byte limit."));
+ return;
+ }
+ }
+ extensions.Add(CreateExtension(entries));
+ }
+
+ nodeSet.Extensions = extensions.Count == 0 ? null : [.. extensions];
+ }
+
+ public static byte[] Apply(
+ byte[] generatedJson,
+ UANodeSet nodeSet,
+ WotNodeSetConverterOptions options,
+ List diagnostics)
+ {
+ List entries = ReadEntries(nodeSet, options, diagnostics);
+ if (entries.Count == 0)
+ {
+ return generatedJson;
+ }
+
+ JsonNode? root;
+ try
+ {
+ root = JsonNode.Parse(
+ Encoding.UTF8.GetString(generatedJson),
+ nodeOptions: null,
+ documentOptions: new JsonDocumentOptions
+ {
+ MaxDepth = options.MaxJsonDepth,
+ CommentHandling = JsonCommentHandling.Disallow
+ });
+ }
+ catch (JsonException ex)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"The generated WoT document could not be parsed before " +
+ $"applying residue: {ex.Message}"));
+ return generatedJson;
+ }
+ if (root is null)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ "The generated WoT document could not be parsed before applying residue."));
+ return generatedJson;
+ }
+
+ foreach (Entry entry in entries)
+ {
+ JsonNode? value;
+ try
+ {
+ value = JsonNode.Parse(
+ entry.Json,
+ nodeOptions: null,
+ documentOptions: new JsonDocumentOptions
+ {
+ MaxDepth = options.MaxJsonDepth,
+ CommentHandling = JsonCommentHandling.Disallow
+ });
+ }
+ catch (JsonException ex)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue at '{entry.Pointer}' is not valid JSON: {ex.Message}",
+ WotLocation.FromPointer(entry.Pointer)));
+ continue;
+ }
+ if (value is null && !string.Equals(entry.Json, "null", StringComparison.Ordinal))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue at '{entry.Pointer}' could not be parsed.",
+ WotLocation.FromPointer(entry.Pointer)));
+ continue;
+ }
+ if (entry.LinkRel is not null)
+ {
+ ApplyLinkEntry(root, entry, value, diagnostics);
+ }
+ else
+ {
+ ApplyEntry(root, entry.Pointer, value, diagnostics);
+ }
+ }
+
+ try
+ {
+ return Encoding.UTF8.GetBytes(root.ToJsonString(
+ new JsonSerializerOptions
+ {
+ WriteIndented = true,
+ MaxDepth = options.MaxJsonDepth
+ }));
+ }
+ catch (Exception ex) when (ex is JsonException or InvalidOperationException)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"The WoT residue exceeds the configured JSON depth: {ex.Message}"));
+ return generatedJson;
+ }
+ }
+
+ private static List Capture(JsonElement root)
+ {
+ var entries = new List();
+ if (root.ValueKind != JsonValueKind.Object)
+ {
+ return entries;
+ }
+ foreach (JsonProperty property in root.EnumerateObject())
+ {
+ string pointer = "/" + Escape(property.Name);
+ switch (property.Name)
+ {
+ case "@context":
+ CaptureContext(property.Value, pointer, entries);
+ break;
+ case "properties":
+ case "actions":
+ case "events":
+ CaptureAffordanceMap(property.Value, pointer, entries);
+ break;
+ case "links":
+ CaptureLinks(property.Value, pointer, entries);
+ break;
+ case "@type":
+ case "title":
+ case "description":
+ case "uav:browseName":
+ case "uav:id":
+ case "uav:isEvent":
+ case "uav:hasComponent":
+ case "uav:componentOf":
+ case "uav:nodeSet":
+ case "uav:nodes":
+ break;
+ default:
+ Add(entries, pointer, property.Value);
+ break;
+ }
+ }
+ return entries;
+ }
+
+ private static void CaptureContext(
+ JsonElement context,
+ string pointer,
+ List entries)
+ {
+ if (context.ValueKind != JsonValueKind.Array)
+ {
+ Add(entries, pointer, context);
+ return;
+ }
+
+ foreach (JsonElement item in context.EnumerateArray())
+ {
+ if (item.ValueKind == JsonValueKind.String &&
+ string.Equals(
+ item.GetString(),
+ WotVocabulary.WotContext,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+ if (item.ValueKind == JsonValueKind.Object &&
+ item.TryGetProperty("uav", out JsonElement uav) &&
+ uav.ValueKind == JsonValueKind.String &&
+ string.Equals(
+ uav.GetString(),
+ WotVocabulary.VocabularyNamespace,
+ StringComparison.Ordinal))
+ {
+ foreach (JsonProperty property in item.EnumerateObject())
+ {
+ if (!IsGeneratedContextBinding(property))
+ {
+ Add(
+ entries,
+ pointer + "/1/" + Escape(property.Name),
+ property.Value);
+ }
+ }
+ continue;
+ }
+ Add(entries, pointer + "/-", item);
+ }
+ }
+
+ private static bool IsGeneratedContextBinding(JsonProperty property)
+ {
+ if (property.Name is "uav" or "ua")
+ {
+ return true;
+ }
+ if (!property.Name.StartsWith("ns", StringComparison.Ordinal) ||
+ property.Name.Length == 2)
+ {
+ return false;
+ }
+ for (int ii = 2; ii < property.Name.Length; ii++)
+ {
+ if (!char.IsDigit(property.Name[ii]))
+ {
+ return false;
+ }
+ }
+ return property.Value.ValueKind == JsonValueKind.String;
+ }
+
+ private static void CaptureAffordanceMap(
+ JsonElement map,
+ string pointer,
+ List entries)
+ {
+ if (map.ValueKind != JsonValueKind.Object)
+ {
+ Add(entries, pointer, map);
+ return;
+ }
+ var used = new HashSet(StringComparer.Ordinal);
+ foreach (JsonProperty affordance in map.EnumerateObject())
+ {
+ string projectedName = affordance.Name;
+ if (affordance.Value.ValueKind == JsonValueKind.Object &&
+ affordance.Value.TryGetProperty(
+ "uav:browseName",
+ out JsonElement browseName) &&
+ browseName.ValueKind == JsonValueKind.String &&
+ LocalName(browseName.GetString()) is { Length: > 0 } localName)
+ {
+ projectedName = localName;
+ }
+ projectedName = UniqueKey(projectedName, used);
+ string affordancePointer = pointer + "/" + Escape(projectedName);
+ if (affordance.Value.ValueKind != JsonValueKind.Object)
+ {
+ Add(entries, affordancePointer, affordance.Value);
+ continue;
+ }
+ foreach (JsonProperty property in affordance.Value.EnumerateObject())
+ {
+ switch (property.Name)
+ {
+ case "@type":
+ case "title":
+ case "description":
+ case "uav:browseName":
+ case "uav:id":
+ case "uav:isEvent":
+ case "uav:modellingRule":
+ case "type":
+ case "readOnly":
+ case "writeOnly":
+ case "observable":
+ break;
+ default:
+ Add(
+ entries,
+ affordancePointer + "/" + Escape(property.Name),
+ property.Value);
+ break;
+ }
+ }
+ }
+ }
+
+ private static string UniqueKey(string candidate, HashSet used)
+ {
+ if (used.Add(candidate))
+ {
+ return candidate;
+ }
+ int suffix = 2;
+ string unique = candidate + "_" +
+ suffix.ToString(CultureInfo.InvariantCulture);
+ while (!used.Add(unique))
+ {
+ suffix++;
+ unique = candidate + "_" +
+ suffix.ToString(CultureInfo.InvariantCulture);
+ }
+ return unique;
+ }
+
+ private static void CaptureLinks(
+ JsonElement links,
+ string pointer,
+ List entries)
+ {
+ if (links.ValueKind != JsonValueKind.Array)
+ {
+ Add(entries, pointer, links);
+ return;
+ }
+ foreach (JsonElement link in links.EnumerateArray())
+ {
+ string? rel = link.ValueKind == JsonValueKind.Object &&
+ link.TryGetProperty("rel", out JsonElement relElement) &&
+ relElement.ValueKind == JsonValueKind.String
+ ? relElement.GetString()
+ : null;
+ if (!IsMappedLink(rel, link))
+ {
+ Add(entries, pointer + "/-", link);
+ continue;
+ }
+ string extras = GetLinkExtras(link, out bool hasExtras);
+ if (hasExtras)
+ {
+ entries.Add(new Entry
+ {
+ Pointer = pointer + "/-",
+ Json = extras,
+ LinkRel = rel,
+ LinkHref = GetString(link, "href"),
+ LinkRefId = GetString(link, "uav:refId"),
+ LinkRefName = GetString(link, "uav:refName")
+ });
+ }
+ }
+ }
+
+ private static string GetLinkExtras(JsonElement link, out bool hasExtras)
+ {
+ using var stream = new System.IO.MemoryStream();
+ using (var writer = new Utf8JsonWriter(stream))
+ {
+ writer.WriteStartObject();
+ hasExtras = false;
+ foreach (JsonProperty property in link.EnumerateObject())
+ {
+ if (property.Name is "rel" or "href" or "uav:refId" or
+ "uav:refName")
+ {
+ continue;
+ }
+ hasExtras = true;
+ writer.WritePropertyName(property.Name);
+ property.Value.WriteTo(writer);
+ }
+ writer.WriteEndObject();
+ }
+ return Encoding.UTF8.GetString(stream.ToArray());
+ }
+
+ private static bool IsMappedLink(string? rel, JsonElement link)
+ {
+ if (rel is "tm:extends" or
+ "uav:reference" or
+ "uav:componentModel" or
+ "uav:capability")
+ {
+ return true;
+ }
+ if (rel is null || rel.StartsWith("uav:", StringComparison.Ordinal))
+ {
+ return false;
+ }
+ return rel.StartsWith("ua:", StringComparison.Ordinal) ||
+ StartsWithGeneratedNamespacePrefix(rel) ||
+ link.TryGetProperty("uav:refId", out _);
+ }
+
+ private static bool StartsWithGeneratedNamespacePrefix(string rel)
+ {
+ if (!rel.StartsWith("ns", StringComparison.Ordinal))
+ {
+ return false;
+ }
+ int ii = 2;
+ while (ii < rel.Length && char.IsDigit(rel[ii]))
+ {
+ ii++;
+ }
+ return ii > 2 && ii < rel.Length && rel[ii] == ':';
+ }
+
+ private static string? LocalName(string? browseName)
+ {
+ if (string.IsNullOrEmpty(browseName))
+ {
+ return null;
+ }
+ if (browseName!.StartsWith("nsu=", StringComparison.Ordinal))
+ {
+ for (int ii = 4; ii < browseName.Length; ii++)
+ {
+ if (browseName[ii] == ';')
+ {
+ return ii + 1 < browseName.Length
+ ? browseName.Substring(ii + 1)
+ : null;
+ }
+ }
+ return null;
+ }
+ int separator = -1;
+ for (int ii = 0; ii < browseName.Length; ii++)
+ {
+ if (browseName[ii] == ':')
+ {
+ separator = ii;
+ break;
+ }
+ }
+ return separator >= 0 && separator + 1 < browseName.Length
+ ? browseName.Substring(separator + 1)
+ : browseName;
+ }
+
+ private static string? GetString(JsonElement element, string name)
+ {
+ return element.ValueKind == JsonValueKind.Object &&
+ element.TryGetProperty(name, out JsonElement value) &&
+ value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+ }
+
+ private static void Add(
+ List entries,
+ string pointer,
+ JsonElement value)
+ {
+ entries.Add(new Entry
+ {
+ Pointer = pointer,
+ Json = value.GetRawText()
+ });
+ }
+
+ private static System.Xml.XmlElement CreateExtension(List entries)
+ {
+ var document = new XmlDocument { XmlResolver = null };
+ System.Xml.XmlElement root = document.CreateElement(
+ "uav",
+ ResidueElement,
+ WotVocabulary.VocabularyNamespace);
+ root.SetAttribute("Version", Version);
+ document.AppendChild(root);
+
+ foreach (Entry entry in entries)
+ {
+ byte[] bytes = Encoding.UTF8.GetBytes(entry.Json);
+ System.Xml.XmlElement member = document.CreateElement(
+ "uav",
+ MemberElement,
+ WotVocabulary.VocabularyNamespace);
+ member.SetAttribute("Pointer", entry.Pointer);
+ member.SetAttribute("Encoding", WotVocabulary.Base64Encoding);
+ member.SetAttribute("Sha256", ToLowerHex(ComputeSha256(bytes)));
+ SetOptionalAttribute(member, "LinkRel", entry.LinkRel);
+ SetOptionalAttribute(member, "LinkHref", entry.LinkHref);
+ SetOptionalAttribute(member, "LinkRefId", entry.LinkRefId);
+ SetOptionalAttribute(member, "LinkRefName", entry.LinkRefName);
+ member.InnerText = Convert.ToBase64String(bytes);
+ root.AppendChild(member);
+ }
+ return root;
+ }
+
+ private static void SetOptionalAttribute(
+ System.Xml.XmlElement element,
+ string name,
+ string? value)
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ element.SetAttribute(name, value);
+ }
+ }
+
+ private static List ReadEntries(
+ UANodeSet nodeSet,
+ WotNodeSetConverterOptions options,
+ List diagnostics)
+ {
+ var entries = new List();
+ if (nodeSet.Extensions is null)
+ {
+ return entries;
+ }
+
+ int total = 0;
+ foreach (System.Xml.XmlElement extension in nodeSet.Extensions)
+ {
+ if (!IsResidue(extension))
+ {
+ continue;
+ }
+ if (!string.Equals(
+ extension.GetAttribute("Version"),
+ Version,
+ StringComparison.Ordinal))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Unsupported {ResidueElement} Version " +
+ $"'{extension.GetAttribute("Version")}'."));
+ continue;
+ }
+ foreach (XmlNode child in extension.ChildNodes)
+ {
+ if (child is not System.Xml.XmlElement member ||
+ !string.Equals(
+ member.LocalName,
+ MemberElement,
+ StringComparison.Ordinal) ||
+ !string.Equals(
+ member.NamespaceURI,
+ WotVocabulary.VocabularyNamespace,
+ StringComparison.Ordinal))
+ {
+ continue;
+ }
+ string pointer = member.GetAttribute("Pointer");
+ if (!IsJsonPointer(pointer, options.MaxJsonDepth))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue pointer '{pointer}' is not an RFC 6901 JSON Pointer " +
+ $"within the configured depth of {options.MaxJsonDepth}."));
+ continue;
+ }
+ if (!string.Equals(
+ member.GetAttribute("Encoding"),
+ WotVocabulary.Base64Encoding,
+ StringComparison.Ordinal))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue at '{pointer}' does not use base64 encoding.",
+ WotLocation.FromPointer(pointer)));
+ continue;
+ }
+
+ byte[] bytes;
+ try
+ {
+ bytes = Convert.FromBase64String(member.InnerText);
+ }
+ catch (FormatException)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue at '{pointer}' is not valid base64.",
+ WotLocation.FromPointer(pointer)));
+ continue;
+ }
+ total += bytes.Length;
+ if (total > options.MaxJsonDocumentSize)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.JsonDocumentTooLarge,
+ $"WoT residue exceeds the configured " +
+ $"{options.MaxJsonDocumentSize} byte limit."));
+ return entries;
+ }
+ string digest = member.GetAttribute("Sha256");
+ if (!string.Equals(
+ digest,
+ ToLowerHex(ComputeSha256(bytes)),
+ StringComparison.Ordinal))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue at '{pointer}' failed its SHA-256 integrity check.",
+ WotLocation.FromPointer(pointer)));
+ continue;
+ }
+ entries.Add(new Entry
+ {
+ Pointer = pointer,
+ Json = Encoding.UTF8.GetString(bytes),
+ LinkRel = OptionalAttribute(member, "LinkRel"),
+ LinkHref = OptionalAttribute(member, "LinkHref"),
+ LinkRefId = OptionalAttribute(member, "LinkRefId"),
+ LinkRefName = OptionalAttribute(member, "LinkRefName")
+ });
+ }
+ }
+ return entries;
+ }
+
+ private static string? OptionalAttribute(
+ System.Xml.XmlElement element,
+ string name)
+ {
+ string value = element.GetAttribute(name);
+ return value.Length == 0 ? null : value;
+ }
+
+ private static bool IsResidue(System.Xml.XmlElement element)
+ {
+ return string.Equals(
+ element.LocalName,
+ ResidueElement,
+ StringComparison.Ordinal) &&
+ string.Equals(
+ element.NamespaceURI,
+ WotVocabulary.VocabularyNamespace,
+ StringComparison.Ordinal);
+ }
+
+ private static void ApplyEntry(
+ JsonNode root,
+ string pointer,
+ JsonNode? value,
+ List diagnostics)
+ {
+ string[] tokens = ParsePointer(pointer);
+ if (tokens.Length == 0)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ "The document root cannot be a residue target.",
+ WotLocation.FromPointer(pointer)));
+ return;
+ }
+
+ JsonNode current = root;
+ for (int ii = 0; ii < tokens.Length - 1; ii++)
+ {
+ string token = tokens[ii];
+ string next = tokens[ii + 1];
+ if (current is JsonObject obj)
+ {
+ JsonNode? child = obj[token];
+ if (child is null)
+ {
+ child = IsArrayToken(next) ? new JsonArray() : new JsonObject();
+ obj[token] = child;
+ }
+ current = child;
+ }
+ else if (current is JsonArray array &&
+ int.TryParse(
+ token,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out int index) &&
+ index >= 0 &&
+ index < array.Count &&
+ array[index] is JsonNode child)
+ {
+ current = child;
+ }
+ else
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue parent '{pointer}' does not resolve.",
+ WotLocation.FromPointer(pointer)));
+ return;
+ }
+ }
+
+ string leaf = tokens[^1];
+ if (current is JsonObject targetObject)
+ {
+ JsonNode? existing = targetObject[leaf];
+ if (existing is not null)
+ {
+ if (!JsonEquals(existing, value))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueConflict,
+ $"Residue at '{pointer}' conflicts with a value " +
+ "reconstructed from OPC UA model facts.",
+ WotLocation.FromPointer(pointer)));
+ }
+ return;
+ }
+ targetObject[leaf] = value;
+ return;
+ }
+ if (current is JsonArray targetArray)
+ {
+ if (string.Equals(leaf, "-", StringComparison.Ordinal))
+ {
+ targetArray.Add(value);
+ return;
+ }
+ if (int.TryParse(
+ leaf,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out int index) &&
+ index >= 0 &&
+ index <= targetArray.Count)
+ {
+ if (index == targetArray.Count)
+ {
+ targetArray.Add(value);
+ }
+ else if (targetArray[index] is null)
+ {
+ targetArray[index] = value;
+ }
+ else if (!JsonEquals(targetArray[index], value))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueConflict,
+ $"Residue at '{pointer}' conflicts with an existing array item.",
+ WotLocation.FromPointer(pointer)));
+ }
+ return;
+ }
+ }
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ $"Residue target '{pointer}' is invalid.",
+ WotLocation.FromPointer(pointer)));
+ }
+
+ private static void ApplyLinkEntry(
+ JsonNode root,
+ Entry entry,
+ JsonNode? value,
+ List diagnostics)
+ {
+ if (root is not JsonObject rootObject || value is not JsonObject extras)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueInvalid,
+ "A link residue selector requires an object value.",
+ WotLocation.FromPointer(entry.Pointer)));
+ return;
+ }
+
+ JsonArray links;
+ if (rootObject["links"] is JsonArray existingLinks)
+ {
+ links = existingLinks;
+ }
+ else if (rootObject["links"] is null)
+ {
+ links = new JsonArray();
+ rootObject["links"] = links;
+ }
+ else
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueConflict,
+ "Link residue conflicts with a non-array links member.",
+ WotLocation.FromPointer("/links")));
+ return;
+ }
+
+ JsonObject? target = FindLink(links, entry, requireExactRel: true);
+ bool exact = target is not null;
+ target ??= FindLink(links, entry, requireExactRel: false);
+ if (target is null)
+ {
+ target = new JsonObject();
+ SetString(target, "rel", entry.LinkRel);
+ SetString(target, "href", entry.LinkHref);
+ SetString(target, "uav:refId", entry.LinkRefId);
+ SetString(target, "uav:refName", entry.LinkRefName);
+ links.Add(target);
+ }
+ else if (exact)
+ {
+ MergeString(target, "rel", entry.LinkRel, entry.Pointer, diagnostics);
+ MergeString(target, "href", entry.LinkHref, entry.Pointer, diagnostics);
+ MergeString(
+ target,
+ "uav:refId",
+ entry.LinkRefId,
+ entry.Pointer,
+ diagnostics);
+ MergeString(
+ target,
+ "uav:refName",
+ entry.LinkRefName,
+ entry.Pointer,
+ diagnostics);
+ }
+
+ foreach (KeyValuePair property in extras)
+ {
+ JsonNode? existing = target[property.Key];
+ if (existing is not null)
+ {
+ if (!JsonEquals(existing, property.Value))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueConflict,
+ $"Link residue member '{property.Key}' conflicts with " +
+ "a regenerated value.",
+ WotLocation.FromPointer(entry.Pointer)));
+ }
+ continue;
+ }
+ target[property.Key] = CloneNode(property.Value);
+ }
+ }
+
+ private static JsonObject? FindLink(
+ JsonArray links,
+ Entry entry,
+ bool requireExactRel)
+ {
+ foreach (JsonNode? item in links)
+ {
+ if (item is not JsonObject link ||
+ !StringNodeEquals(link["href"], entry.LinkHref))
+ {
+ continue;
+ }
+ if (requireExactRel)
+ {
+ if (StringNodeEquals(link["rel"], entry.LinkRel))
+ {
+ return link;
+ }
+ continue;
+ }
+ if (entry.LinkRefId is not null &&
+ StringNodeEquals(link["uav:refId"], entry.LinkRefId))
+ {
+ return link;
+ }
+ }
+ return null;
+ }
+
+ private static bool StringNodeEquals(JsonNode? node, string? value)
+ {
+ return node is JsonValue jsonValue &&
+ jsonValue.TryGetValue(out string? text) &&
+ string.Equals(text, value, StringComparison.Ordinal);
+ }
+
+ private static void SetString(
+ JsonObject target,
+ string name,
+ string? value)
+ {
+ if (value is not null)
+ {
+ target[name] = value;
+ }
+ }
+
+ private static void MergeString(
+ JsonObject target,
+ string name,
+ string? value,
+ string pointer,
+ List diagnostics)
+ {
+ if (value is null)
+ {
+ return;
+ }
+ JsonNode? existing = target[name];
+ if (existing is null)
+ {
+ target[name] = value;
+ }
+ else if (!StringNodeEquals(existing, value))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.ResidueConflict,
+ $"Link residue selector '{name}' conflicts with a regenerated value.",
+ WotLocation.FromPointer(pointer)));
+ }
+ }
+
+ private static JsonNode? CloneNode(JsonNode? value)
+ {
+ return value?.DeepClone();
+ }
+
+ private static bool JsonEquals(JsonNode? left, JsonNode? right)
+ {
+ return string.Equals(
+ left?.ToJsonString() ?? "null",
+ right?.ToJsonString() ?? "null",
+ StringComparison.Ordinal);
+ }
+
+ private static bool IsArrayToken(string token)
+ {
+ return string.Equals(token, "-", StringComparison.Ordinal) ||
+ int.TryParse(
+ token,
+ NumberStyles.None,
+ CultureInfo.InvariantCulture,
+ out _);
+ }
+
+ private static bool IsJsonPointer(string pointer, int maxDepth)
+ {
+ if (string.IsNullOrEmpty(pointer) || pointer[0] != '/')
+ {
+ return false;
+ }
+ string[] tokens = pointer.Substring(1).Split('/');
+ if (tokens.Length >= maxDepth)
+ {
+ return false;
+ }
+ foreach (string token in tokens)
+ {
+ for (int ii = 0; ii < token.Length; ii++)
+ {
+ if (token[ii] == '~' &&
+ (ii + 1 >= token.Length ||
+ token[ii + 1] is not ('0' or '1')))
+ {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static string[] ParsePointer(string pointer)
+ {
+ string[] tokens = pointer.Substring(1).Split('/');
+ for (int ii = 0; ii < tokens.Length; ii++)
+ {
+ tokens[ii] = ReplaceOrdinal(
+ ReplaceOrdinal(tokens[ii], "~1", "/"),
+ "~0",
+ "~");
+ }
+ return tokens;
+ }
+
+ private static string Escape(string token)
+ {
+ return ReplaceOrdinal(
+ ReplaceOrdinal(token, "~", "~0"),
+ "/",
+ "~1");
+ }
+
+ private static string ReplaceOrdinal(
+ string source,
+ string oldValue,
+ string newValue)
+ {
+ int index = source.IndexOf(oldValue, StringComparison.Ordinal);
+ if (index < 0)
+ {
+ return source;
+ }
+ var builder = new StringBuilder(source.Length);
+ int start = 0;
+ while (index >= 0)
+ {
+ builder.Append(source, start, index - start);
+ builder.Append(newValue);
+ start = index + oldValue.Length;
+ index = source.IndexOf(oldValue, start, StringComparison.Ordinal);
+ }
+ builder.Append(source, start, source.Length - start);
+ return builder.ToString();
+ }
+
+ private static byte[] ComputeSha256(byte[] data)
+ {
+#if NET6_0_OR_GREATER
+ return SHA256.HashData(data);
+#else
+ using SHA256 sha256 = SHA256.Create();
+ return sha256.ComputeHash(data);
+#endif
+ }
+
+ private static string ToLowerHex(byte[] data)
+ {
+ var builder = new StringBuilder(data.Length * 2);
+ foreach (byte value in data)
+ {
+ builder.Append(value.ToString("x2", CultureInfo.InvariantCulture));
+ }
+ return builder.ToString();
+ }
+ }
+}
diff --git a/src/Opc.Ua.Types/Wot/WotNativeProjection.cs b/src/Opc.Ua.Types/Wot/WotNativeProjection.cs
new file mode 100644
index 0000000000..4f75a3e777
--- /dev/null
+++ b/src/Opc.Ua.Types/Wot/WotNativeProjection.cs
@@ -0,0 +1,1437 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.IO;
+using System.Text.Json;
+using System.Xml;
+using Opc.Ua.Export;
+
+namespace Opc.Ua.Wot
+{
+ ///
+ /// Schema-complete, deterministic JSON projection of the UANodeSet XSD.
+ ///
+ internal static class WotNativeProjection
+ {
+ public const string ProjectionType = "uav:NodeModel";
+ public const string ProfileVersion = "1.0";
+
+ public static byte[] Write(
+ UANodeSet nodeSet,
+ WotNodeSetConverterOptions options,
+ List diagnostics)
+ {
+ using var output = new MemoryStream();
+ using (var writer = new Utf8JsonWriter(
+ output,
+ new JsonWriterOptions { Indented = true, SkipValidation = false }))
+ {
+ writer.WriteStartObject();
+ writer.WriteString("@type", ProjectionType);
+ writer.WriteString("profileVersion", ProfileVersion);
+ WriteStrings(writer, "namespaceUris", nodeSet.NamespaceUris);
+ WriteStrings(writer, "serverUris", nodeSet.ServerUris);
+ WriteModels(writer, nodeSet.Models);
+ WriteAliases(writer, nodeSet.Aliases);
+ WriteXmlElements(writer, "extensions", nodeSet.Extensions);
+ if (nodeSet.LastModifiedSpecified)
+ {
+ writer.WriteString("lastModified", FormatDate(nodeSet.LastModified));
+ }
+
+ writer.WritePropertyName("nodes");
+ writer.WriteStartArray();
+ if (nodeSet.Items is not null)
+ {
+ int count = 0;
+ foreach (UANode node in nodeSet.Items)
+ {
+ if (count++ >= options.MaxNodeCount)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NodeCountExceeded,
+ $"The NodeSet contains more than the configured " +
+ $"{options.MaxNodeCount} native projection nodes."));
+ break;
+ }
+ WriteNode(writer, node);
+ }
+ }
+ writer.WriteEndArray();
+ writer.WriteEndObject();
+ }
+ return output.ToArray();
+ }
+
+ public static UANodeSet? Read(
+ JsonElement projection,
+ WotNodeSetConverterOptions options,
+ List diagnostics)
+ {
+ int initialErrors = CountErrors(diagnostics);
+ if (projection.ValueKind != JsonValueKind.Object ||
+ !string.Equals(
+ GetString(projection, "@type"),
+ ProjectionType,
+ StringComparison.Ordinal))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ $"The uav:nodes member shall be an object whose @type is " +
+ $"{ProjectionType}.",
+ WotLocation.FromPointer("/uav:nodes")));
+ return null;
+ }
+
+ string? version = GetString(projection, "profileVersion");
+ if (!string.Equals(version, ProfileVersion, StringComparison.Ordinal))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ $"Unsupported uav:nodes profileVersion '{version}'.",
+ WotLocation.FromPointer("/uav:nodes/profileVersion")));
+ return null;
+ }
+
+ var nodeSet = new UANodeSet
+ {
+ NamespaceUris = ReadStrings(projection, "namespaceUris"),
+ ServerUris = ReadStrings(projection, "serverUris"),
+ Models = ReadModels(projection, diagnostics),
+ Aliases = ReadAliases(projection, diagnostics),
+ Extensions = ReadXmlElements(
+ projection,
+ "extensions",
+ "/uav:nodes/extensions",
+ diagnostics)
+ };
+
+ if (TryGetDate(projection, "lastModified", out DateTime lastModified))
+ {
+ nodeSet.LastModified = lastModified;
+ nodeSet.LastModifiedSpecified = true;
+ }
+
+ if (!projection.TryGetProperty("nodes", out JsonElement nodes) ||
+ nodes.ValueKind != JsonValueKind.Array)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ "The uav:nodes projection is missing its nodes array.",
+ WotLocation.FromPointer("/uav:nodes/nodes")));
+ return null;
+ }
+
+ var items = new List();
+ int index = 0;
+ foreach (JsonElement element in nodes.EnumerateArray())
+ {
+ if (items.Count >= options.MaxNodeCount)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NodeCountExceeded,
+ $"The native projection contains more than the configured " +
+ $"{options.MaxNodeCount} nodes.",
+ WotLocation.FromPointer("/uav:nodes/nodes")));
+ break;
+ }
+ UANode? node = ReadNode(
+ element,
+ $"/uav:nodes/nodes/{index.ToString(CultureInfo.InvariantCulture)}",
+ diagnostics);
+ if (node is not null)
+ {
+ items.Add(node);
+ }
+ index++;
+ }
+ nodeSet.Items = [.. items];
+
+ return CountErrors(diagnostics) == initialErrors ? nodeSet : null;
+ }
+
+ private static void WriteModels(Utf8JsonWriter writer, ModelTableEntry[]? models)
+ {
+ if (models is null || models.Length == 0)
+ {
+ return;
+ }
+ writer.WritePropertyName("models");
+ writer.WriteStartArray();
+ foreach (ModelTableEntry model in models)
+ {
+ WriteModel(writer, model);
+ }
+ writer.WriteEndArray();
+ }
+
+ private static void WriteModel(Utf8JsonWriter writer, ModelTableEntry model)
+ {
+ writer.WriteStartObject();
+ WriteString(writer, "modelUri", model.ModelUri);
+ WriteString(writer, "xmlSchemaUri", model.XmlSchemaUri);
+ WriteString(writer, "version", model.Version);
+ if (model.PublicationDateSpecified)
+ {
+ writer.WriteString("publicationDate", FormatDate(model.PublicationDate));
+ }
+ WriteString(writer, "modelVersion", model.ModelVersion);
+ if (model.AccessRestrictions != 0)
+ {
+ writer.WriteNumber("accessRestrictions", model.AccessRestrictions);
+ }
+ WriteRolePermissions(writer, model.RolePermissions);
+ if (model.RequiredModel is { Length: > 0 })
+ {
+ writer.WritePropertyName("requiredModels");
+ writer.WriteStartArray();
+ foreach (ModelTableEntry required in model.RequiredModel)
+ {
+ WriteModel(writer, required);
+ }
+ writer.WriteEndArray();
+ }
+ writer.WriteEndObject();
+ }
+
+ private static ModelTableEntry[]? ReadModels(
+ JsonElement projection,
+ List diagnostics)
+ {
+ if (!projection.TryGetProperty("models", out JsonElement models) ||
+ models.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+ var result = new List();
+ int index = 0;
+ foreach (JsonElement model in models.EnumerateArray())
+ {
+ result.Add(ReadModel(
+ model,
+ $"/uav:nodes/models/{index.ToString(CultureInfo.InvariantCulture)}",
+ diagnostics));
+ index++;
+ }
+ return [.. result];
+ }
+
+ private static ModelTableEntry ReadModel(
+ JsonElement element,
+ string pointer,
+ List diagnostics)
+ {
+ var model = new ModelTableEntry
+ {
+ ModelUri = GetString(element, "modelUri"),
+ XmlSchemaUri = GetString(element, "xmlSchemaUri"),
+ Version = GetString(element, "version"),
+ ModelVersion = GetString(element, "modelVersion"),
+ RolePermissions = ReadRolePermissions(element, "rolePermissions")
+ };
+ if (TryGetDate(element, "publicationDate", out DateTime publicationDate))
+ {
+ model.PublicationDate = publicationDate;
+ model.PublicationDateSpecified = true;
+ }
+ if (TryGetUInt16(element, "accessRestrictions", out ushort accessRestrictions))
+ {
+ model.AccessRestrictions = accessRestrictions;
+ }
+ if (element.TryGetProperty("requiredModels", out JsonElement required) &&
+ required.ValueKind == JsonValueKind.Array)
+ {
+ var entries = new List();
+ int index = 0;
+ foreach (JsonElement item in required.EnumerateArray())
+ {
+ entries.Add(ReadModel(
+ item,
+ pointer + "/requiredModels/" +
+ index.ToString(CultureInfo.InvariantCulture),
+ diagnostics));
+ index++;
+ }
+ model.RequiredModel = [.. entries];
+ }
+ if (string.IsNullOrEmpty(model.ModelUri))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ "A native model entry is missing modelUri.",
+ WotLocation.FromPointer(pointer)));
+ }
+ return model;
+ }
+
+ private static void WriteAliases(Utf8JsonWriter writer, NodeIdAlias[]? aliases)
+ {
+ if (aliases is null || aliases.Length == 0)
+ {
+ return;
+ }
+ writer.WritePropertyName("aliases");
+ writer.WriteStartArray();
+ foreach (NodeIdAlias alias in aliases)
+ {
+ writer.WriteStartObject();
+ WriteString(writer, "alias", alias.Alias);
+ WriteString(writer, "value", alias.Value);
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ }
+
+ private static NodeIdAlias[]? ReadAliases(
+ JsonElement projection,
+ List diagnostics)
+ {
+ if (!projection.TryGetProperty("aliases", out JsonElement aliases) ||
+ aliases.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+ var result = new List();
+ int index = 0;
+ foreach (JsonElement item in aliases.EnumerateArray())
+ {
+ string? alias = GetString(item, "alias");
+ string? value = GetString(item, "value");
+ if (string.IsNullOrEmpty(alias) || string.IsNullOrEmpty(value))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ "A native alias entry requires alias and value.",
+ WotLocation.FromPointer(
+ "/uav:nodes/aliases/" +
+ index.ToString(CultureInfo.InvariantCulture))));
+ }
+ result.Add(new NodeIdAlias { Alias = alias, Value = value });
+ index++;
+ }
+ return [.. result];
+ }
+
+ private static void WriteNode(Utf8JsonWriter writer, UANode node)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("nodeClass", GetNodeClass(node));
+ WriteString(writer, "nodeId", node.NodeId);
+ WriteString(writer, "browseName", node.BrowseName);
+ WriteString(writer, "symbolicName", node.SymbolicName);
+ WriteTexts(writer, "displayName", node.DisplayName);
+ WriteTexts(writer, "description", node.Description);
+ WriteStrings(writer, "category", node.Category);
+ WriteString(writer, "documentation", node.Documentation);
+ WriteReferences(writer, node.References);
+ WriteRolePermissions(writer, node.RolePermissions);
+ WriteXmlElements(writer, "extensions", node.Extensions);
+ if (node.WriteMask != 0)
+ {
+ writer.WriteNumber("writeMask", node.WriteMask);
+ }
+ if (node.UserWriteMask != 0)
+ {
+ writer.WriteNumber("userWriteMask", node.UserWriteMask);
+ }
+ if (node.AccessRestrictionsSpecified)
+ {
+ writer.WriteNumber("accessRestrictions", node.AccessRestrictions);
+ }
+ if (node.HasNoPermissions)
+ {
+ writer.WriteBoolean("hasNoPermissions", true);
+ }
+ if (node.ReleaseStatus != ReleaseStatus.Released)
+ {
+ writer.WriteString("releaseStatus", node.ReleaseStatus.ToString());
+ }
+
+ switch (node)
+ {
+ case UAVariable variable:
+ WriteInstance(writer, variable);
+ WriteVariable(writer, variable);
+ break;
+ case UAVariableType variableType:
+ WriteType(writer, variableType);
+ WriteVariableType(writer, variableType);
+ break;
+ case UAObject uaObject:
+ WriteInstance(writer, uaObject);
+ if (uaObject.EventNotifier != 0)
+ {
+ writer.WriteNumber("eventNotifier", uaObject.EventNotifier);
+ }
+ break;
+ case UAMethod method:
+ WriteInstance(writer, method);
+ WriteMethod(writer, method);
+ break;
+ case UAView view:
+ WriteInstance(writer, view);
+ if (view.ContainsNoLoops)
+ {
+ writer.WriteBoolean("containsNoLoops", true);
+ }
+ if (view.EventNotifier != 0)
+ {
+ writer.WriteNumber("eventNotifier", view.EventNotifier);
+ }
+ break;
+ case UADataType dataType:
+ WriteType(writer, dataType);
+ WriteDataType(writer, dataType);
+ break;
+ case UAReferenceType referenceType:
+ WriteType(writer, referenceType);
+ WriteTexts(writer, "inverseName", referenceType.InverseName);
+ if (referenceType.Symmetric)
+ {
+ writer.WriteBoolean("symmetric", true);
+ }
+ break;
+ case UAObjectType objectType:
+ WriteType(writer, objectType);
+ break;
+ }
+
+ WriteDerivedFacts(writer, node);
+ writer.WriteEndObject();
+ }
+
+ private static UANode? ReadNode(
+ JsonElement element,
+ string pointer,
+ List diagnostics)
+ {
+ string? nodeClass = GetString(element, "nodeClass");
+ UANode? node = nodeClass switch
+ {
+ "Object" => new UAObject(),
+ "Variable" => new UAVariable(),
+ "Method" => new UAMethod(),
+ "View" => new UAView(),
+ "ObjectType" => new UAObjectType(),
+ "VariableType" => new UAVariableType(),
+ "DataType" => new UADataType(),
+ "ReferenceType" => new UAReferenceType(),
+ _ => null
+ };
+ if (node is null)
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ $"Unknown native nodeClass '{nodeClass}'.",
+ WotLocation.FromPointer(pointer)));
+ return null;
+ }
+
+ node.NodeId = GetString(element, "nodeId");
+ node.BrowseName = GetString(element, "browseName");
+ node.SymbolicName = GetString(element, "symbolicName");
+ node.DisplayName = ReadTexts(element, "displayName");
+ node.Description = ReadTexts(element, "description");
+ node.Category = ReadStrings(element, "category");
+ node.Documentation = GetString(element, "documentation");
+ node.References = ReadReferences(element);
+ node.RolePermissions = ReadRolePermissions(element, "rolePermissions");
+ node.Extensions = ReadXmlElements(
+ element,
+ "extensions",
+ pointer + "/extensions",
+ diagnostics);
+ if (TryGetUInt32(element, "writeMask", out uint writeMask))
+ {
+ node.WriteMask = writeMask;
+ }
+ if (TryGetUInt32(element, "userWriteMask", out uint userWriteMask))
+ {
+ node.UserWriteMask = userWriteMask;
+ }
+ if (TryGetUInt16(element, "accessRestrictions", out ushort accessRestrictions))
+ {
+ node.AccessRestrictions = accessRestrictions;
+ node.AccessRestrictionsSpecified = true;
+ }
+ node.HasNoPermissions = GetBoolean(element, "hasNoPermissions");
+ string? releaseStatus = GetString(element, "releaseStatus");
+ if (releaseStatus is not null &&
+ Enum.TryParse(releaseStatus, ignoreCase: false, out ReleaseStatus status))
+ {
+ node.ReleaseStatus = status;
+ }
+
+ switch (node)
+ {
+ case UAVariable variable:
+ ReadInstance(element, variable);
+ ReadVariable(element, variable, pointer, diagnostics);
+ break;
+ case UAVariableType variableType:
+ ReadType(element, variableType);
+ ReadVariableType(element, variableType, pointer, diagnostics);
+ break;
+ case UAObject uaObject:
+ ReadInstance(element, uaObject);
+ if (TryGetByte(element, "eventNotifier", out byte objectNotifier))
+ {
+ uaObject.EventNotifier = objectNotifier;
+ }
+ break;
+ case UAMethod method:
+ ReadInstance(element, method);
+ ReadMethod(element, method);
+ break;
+ case UAView view:
+ ReadInstance(element, view);
+ view.ContainsNoLoops = GetBoolean(element, "containsNoLoops");
+ if (TryGetByte(element, "eventNotifier", out byte viewNotifier))
+ {
+ view.EventNotifier = viewNotifier;
+ }
+ break;
+ case UADataType dataType:
+ ReadType(element, dataType);
+ ReadDataType(element, dataType, pointer, diagnostics);
+ break;
+ case UAReferenceType referenceType:
+ ReadType(element, referenceType);
+ referenceType.InverseName = ReadTexts(element, "inverseName");
+ referenceType.Symmetric = GetBoolean(element, "symmetric");
+ break;
+ case UAObjectType objectType:
+ ReadType(element, objectType);
+ break;
+ }
+
+ if (string.IsNullOrEmpty(node.NodeId) || string.IsNullOrEmpty(node.BrowseName))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ "A native node requires nodeId and browseName.",
+ WotLocation.FromPointer(pointer)));
+ }
+ return node;
+ }
+
+ private static void WriteInstance(Utf8JsonWriter writer, UAInstance instance)
+ {
+ WriteString(writer, "parentNodeId", instance.ParentNodeId);
+ if (instance.DesignToolOnly)
+ {
+ writer.WriteBoolean("designToolOnly", true);
+ }
+ }
+
+ private static void ReadInstance(JsonElement element, UAInstance instance)
+ {
+ instance.ParentNodeId = GetString(element, "parentNodeId");
+ instance.DesignToolOnly = GetBoolean(element, "designToolOnly");
+ }
+
+ private static void WriteType(Utf8JsonWriter writer, UAType type)
+ {
+ if (type.IsAbstract)
+ {
+ writer.WriteBoolean("isAbstract", true);
+ }
+ }
+
+ private static void ReadType(JsonElement element, UAType type)
+ {
+ type.IsAbstract = GetBoolean(element, "isAbstract");
+ }
+
+ private static void WriteVariable(Utf8JsonWriter writer, UAVariable variable)
+ {
+ WriteXmlElement(writer, "valueXml", variable.Value);
+ WriteTranslations(writer, variable.Translation);
+ WriteString(writer, "dataType", variable.DataType);
+ if (variable.ValueRank != -1)
+ {
+ writer.WriteNumber("valueRank", variable.ValueRank);
+ }
+ WriteString(writer, "arrayDimensions", variable.ArrayDimensions);
+ if (variable.AccessLevel != 1)
+ {
+ writer.WriteNumber("accessLevel", variable.AccessLevel);
+ }
+ if (variable.UserAccessLevel != 1)
+ {
+ writer.WriteNumber("userAccessLevel", variable.UserAccessLevel);
+ }
+ if (variable.MinimumSamplingInterval != 0D)
+ {
+ writer.WriteNumber("minimumSamplingInterval", variable.MinimumSamplingInterval);
+ }
+ if (variable.Historizing)
+ {
+ writer.WriteBoolean("historizing", true);
+ }
+ }
+
+ private static void ReadVariable(
+ JsonElement element,
+ UAVariable variable,
+ string pointer,
+ List diagnostics)
+ {
+ variable.Value = ReadXmlElement(
+ element,
+ "valueXml",
+ pointer + "/valueXml",
+ diagnostics);
+ variable.Translation = ReadTranslations(element);
+ variable.DataType = GetString(element, "dataType") ?? WotVocabulary.BaseDataType;
+ if (TryGetInt32(element, "valueRank", out int valueRank))
+ {
+ variable.ValueRank = valueRank;
+ }
+ variable.ArrayDimensions = GetString(element, "arrayDimensions") ?? string.Empty;
+ if (TryGetUInt32(element, "accessLevel", out uint accessLevel))
+ {
+ variable.AccessLevel = accessLevel;
+ }
+ if (TryGetUInt32(element, "userAccessLevel", out uint userAccessLevel))
+ {
+ variable.UserAccessLevel = userAccessLevel;
+ }
+ if (TryGetDouble(
+ element,
+ "minimumSamplingInterval",
+ out double minimumSamplingInterval))
+ {
+ variable.MinimumSamplingInterval = minimumSamplingInterval;
+ }
+ variable.Historizing = GetBoolean(element, "historizing");
+ }
+
+ private static void WriteVariableType(
+ Utf8JsonWriter writer,
+ UAVariableType variableType)
+ {
+ WriteXmlElement(writer, "valueXml", variableType.Value);
+ WriteString(writer, "dataType", variableType.DataType);
+ if (variableType.ValueRank != -1)
+ {
+ writer.WriteNumber("valueRank", variableType.ValueRank);
+ }
+ WriteString(writer, "arrayDimensions", variableType.ArrayDimensions);
+ }
+
+ private static void ReadVariableType(
+ JsonElement element,
+ UAVariableType variableType,
+ string pointer,
+ List diagnostics)
+ {
+ variableType.Value = ReadXmlElement(
+ element,
+ "valueXml",
+ pointer + "/valueXml",
+ diagnostics);
+ variableType.DataType =
+ GetString(element, "dataType") ?? WotVocabulary.BaseDataType;
+ if (TryGetInt32(element, "valueRank", out int valueRank))
+ {
+ variableType.ValueRank = valueRank;
+ }
+ variableType.ArrayDimensions =
+ GetString(element, "arrayDimensions") ?? string.Empty;
+ }
+
+ private static void WriteMethod(Utf8JsonWriter writer, UAMethod method)
+ {
+ if (method.ArgumentDescription is { Length: > 0 })
+ {
+ writer.WritePropertyName("argumentDescriptions");
+ writer.WriteStartArray();
+ foreach (UAMethodArgument argument in method.ArgumentDescription)
+ {
+ writer.WriteStartObject();
+ WriteString(writer, "name", argument.Name);
+ WriteTexts(writer, "description", argument.Description);
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ }
+ if (!method.Executable)
+ {
+ writer.WriteBoolean("executable", false);
+ }
+ if (!method.UserExecutable)
+ {
+ writer.WriteBoolean("userExecutable", false);
+ }
+ WriteString(writer, "methodDeclarationId", method.MethodDeclarationId);
+ }
+
+ private static void ReadMethod(JsonElement element, UAMethod method)
+ {
+ if (element.TryGetProperty(
+ "argumentDescriptions",
+ out JsonElement descriptions) &&
+ descriptions.ValueKind == JsonValueKind.Array)
+ {
+ var arguments = new List();
+ foreach (JsonElement argument in descriptions.EnumerateArray())
+ {
+ arguments.Add(new UAMethodArgument
+ {
+ Name = GetString(argument, "name"),
+ Description = ReadTexts(argument, "description")
+ });
+ }
+ method.ArgumentDescription = [.. arguments];
+ }
+ if (element.TryGetProperty("executable", out JsonElement executable) &&
+ executable.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ method.Executable = executable.GetBoolean();
+ }
+ if (element.TryGetProperty(
+ "userExecutable",
+ out JsonElement userExecutable) &&
+ userExecutable.ValueKind is JsonValueKind.True or JsonValueKind.False)
+ {
+ method.UserExecutable = userExecutable.GetBoolean();
+ }
+ method.MethodDeclarationId = GetString(element, "methodDeclarationId");
+ }
+
+ private static void WriteDataType(Utf8JsonWriter writer, UADataType dataType)
+ {
+ if (dataType.Definition is not null)
+ {
+ writer.WritePropertyName("definition");
+ WriteDefinition(writer, dataType.Definition);
+ }
+ if (dataType.Purpose != DataTypePurpose.Normal)
+ {
+ writer.WriteString("purpose", dataType.Purpose.ToString());
+ }
+ }
+
+ private static void ReadDataType(
+ JsonElement element,
+ UADataType dataType,
+ string pointer,
+ List diagnostics)
+ {
+ if (element.TryGetProperty("definition", out JsonElement definition) &&
+ definition.ValueKind == JsonValueKind.Object)
+ {
+ dataType.Definition = ReadDefinition(
+ definition,
+ pointer + "/definition",
+ diagnostics);
+ }
+ string? purpose = GetString(element, "purpose");
+ if (purpose is not null &&
+ Enum.TryParse(purpose, ignoreCase: false, out DataTypePurpose parsed))
+ {
+ dataType.Purpose = parsed;
+ }
+ }
+
+ private static void WriteDefinition(
+ Utf8JsonWriter writer,
+ Opc.Ua.Export.DataTypeDefinition definition)
+ {
+ writer.WriteStartObject();
+ WriteString(writer, "name", definition.Name);
+ WriteString(writer, "symbolicName", definition.SymbolicName);
+ if (definition.IsUnion)
+ {
+ writer.WriteBoolean("isUnion", true);
+ }
+ if (definition.IsOptionSet)
+ {
+ writer.WriteBoolean("isOptionSet", true);
+ }
+ WriteString(writer, "baseType", definition.BaseType);
+ if (definition.Field is { Length: > 0 })
+ {
+ writer.WritePropertyName("fields");
+ writer.WriteStartArray();
+ foreach (Opc.Ua.Export.DataTypeField field in definition.Field)
+ {
+ writer.WriteStartObject();
+ WriteString(writer, "name", field.Name);
+ WriteString(writer, "symbolicName", field.SymbolicName);
+ WriteTexts(writer, "displayName", field.DisplayName);
+ WriteTexts(writer, "description", field.Description);
+ WriteString(writer, "dataType", field.DataType);
+ if (field.ValueRank != -1)
+ {
+ writer.WriteNumber("valueRank", field.ValueRank);
+ }
+ WriteString(writer, "arrayDimensions", field.ArrayDimensions);
+ if (field.MaxStringLength != 0)
+ {
+ writer.WriteNumber("maxStringLength", field.MaxStringLength);
+ }
+ if (field.Value != -1)
+ {
+ writer.WriteNumber("value", field.Value);
+ }
+ if (field.IsOptional)
+ {
+ writer.WriteBoolean("isOptional", true);
+ }
+ if (field.AllowSubTypes)
+ {
+ writer.WriteBoolean("allowSubTypes", true);
+ }
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ }
+ writer.WriteEndObject();
+ }
+
+ private static Opc.Ua.Export.DataTypeDefinition ReadDefinition(
+ JsonElement element,
+ string pointer,
+ List diagnostics)
+ {
+ var definition = new Opc.Ua.Export.DataTypeDefinition
+ {
+ Name = GetString(element, "name"),
+ SymbolicName = GetString(element, "symbolicName"),
+ IsUnion = GetBoolean(element, "isUnion"),
+ IsOptionSet = GetBoolean(element, "isOptionSet"),
+ BaseType = GetString(element, "baseType") ?? string.Empty
+ };
+ if (element.TryGetProperty("fields", out JsonElement fields) &&
+ fields.ValueKind == JsonValueKind.Array)
+ {
+ var result = new List();
+ int index = 0;
+ foreach (JsonElement item in fields.EnumerateArray())
+ {
+ var field = new Opc.Ua.Export.DataTypeField
+ {
+ Name = GetString(item, "name"),
+ SymbolicName = GetString(item, "symbolicName"),
+ DisplayName = ReadTexts(item, "displayName"),
+ Description = ReadTexts(item, "description"),
+ DataType = GetString(item, "dataType") ?? WotVocabulary.BaseDataType,
+ ArrayDimensions =
+ GetString(item, "arrayDimensions") ?? string.Empty,
+ IsOptional = GetBoolean(item, "isOptional"),
+ AllowSubTypes = GetBoolean(item, "allowSubTypes")
+ };
+ if (TryGetInt32(item, "valueRank", out int valueRank))
+ {
+ field.ValueRank = valueRank;
+ }
+ if (TryGetUInt32(item, "maxStringLength", out uint maxStringLength))
+ {
+ field.MaxStringLength = maxStringLength;
+ }
+ if (TryGetInt32(item, "value", out int value))
+ {
+ field.Value = value;
+ }
+ if (string.IsNullOrEmpty(field.Name))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ "A DataType definition field is missing name.",
+ WotLocation.FromPointer(
+ pointer + "/fields/" +
+ index.ToString(CultureInfo.InvariantCulture))));
+ }
+ result.Add(field);
+ index++;
+ }
+ definition.Field = [.. result];
+ }
+ if (string.IsNullOrEmpty(definition.Name))
+ {
+ diagnostics.Add(new WotDiagnostic(
+ WotDiagnosticSeverity.Error,
+ WotDiagnosticCode.NativeProjectionInvalid,
+ "A DataType definition is missing name.",
+ WotLocation.FromPointer(pointer)));
+ }
+ return definition;
+ }
+
+ private static void WriteTranslations(
+ Utf8JsonWriter writer,
+ TranslationType[]? translations)
+ {
+ if (translations is null || translations.Length == 0)
+ {
+ return;
+ }
+ writer.WritePropertyName("translations");
+ writer.WriteStartArray();
+ foreach (TranslationType translation in translations)
+ {
+ writer.WriteStartObject();
+ if (translation.Items is { Length: > 0 })
+ {
+ writer.WritePropertyName("items");
+ writer.WriteStartArray();
+ foreach (object item in translation.Items)
+ {
+ writer.WriteStartObject();
+ switch (item)
+ {
+ case Opc.Ua.Export.LocalizedText text:
+ writer.WriteString("kind", "text");
+ WriteText(writer, text);
+ break;
+ case StructureTranslationType field:
+ writer.WriteString("kind", "field");
+ WriteString(writer, "name", field.Name);
+ WriteTexts(writer, "text", field.Text);
+ break;
+ }
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ }
+ writer.WriteEndObject();
+ }
+ writer.WriteEndArray();
+ }
+
+ private static TranslationType[]? ReadTranslations(JsonElement element)
+ {
+ if (!element.TryGetProperty("translations", out JsonElement translations) ||
+ translations.ValueKind != JsonValueKind.Array)
+ {
+ return null;
+ }
+ var result = new List();
+ foreach (JsonElement translation in translations.EnumerateArray())
+ {
+ var items = new List