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("'); + } + + 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(); + if (translation.TryGetProperty("items", out JsonElement array) && + array.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in array.EnumerateArray()) + { + string? kind = GetString(item, "kind"); + if (string.Equals(kind, "text", StringComparison.Ordinal)) + { + items.Add(ReadText(item)); + } + else if (string.Equals(kind, "field", StringComparison.Ordinal)) + { + items.Add(new StructureTranslationType + { + Name = GetString(item, "name"), + Text = ReadTexts(item, "text") + }); + } + } + } + result.Add(new TranslationType { Items = [.. items] }); + } + return [.. result]; + } + + private static void WriteDerivedFacts(Utf8JsonWriter writer, UANode node) + { + if (node.References is null) + { + return; + } + foreach (Reference reference in node.References) + { + if (reference.IsForward && + IsReference(reference.ReferenceType, "HasTypeDefinition", "i=40")) + { + WriteString(writer, "typeDefinition", reference.Value); + } + else if (!reference.IsForward && + IsReference(reference.ReferenceType, "HasSubtype", "i=45")) + { + WriteString(writer, "superType", reference.Value); + } + else if (reference.IsForward && + IsReference(reference.ReferenceType, "HasModellingRule", "i=37") && + reference.Value is not null && + WotVocabulary.TryGetModellingRuleName( + reference.Value, + out string modellingRule)) + { + writer.WriteString("modellingRule", modellingRule); + } + } + } + + private static bool IsReference(string? value, string name, string nodeId) + { + return string.Equals(value, name, StringComparison.Ordinal) || + string.Equals(value, nodeId, StringComparison.Ordinal); + } + + private static void WriteReferences( + Utf8JsonWriter writer, + Reference[]? references) + { + if (references is null || references.Length == 0) + { + return; + } + writer.WritePropertyName("references"); + writer.WriteStartArray(); + foreach (Reference reference in references) + { + writer.WriteStartObject(); + WriteString(writer, "referenceType", reference.ReferenceType); + if (!reference.IsForward) + { + writer.WriteBoolean("isForward", false); + } + WriteString(writer, "target", reference.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + private static Reference[]? ReadReferences(JsonElement element) + { + if (!element.TryGetProperty("references", out JsonElement references) || + references.ValueKind != JsonValueKind.Array) + { + return null; + } + var result = new List(); + foreach (JsonElement reference in references.EnumerateArray()) + { + result.Add(new Reference + { + ReferenceType = GetString(reference, "referenceType"), + IsForward = !reference.TryGetProperty( + "isForward", + out JsonElement isForward) || + isForward.ValueKind != JsonValueKind.False, + Value = GetString(reference, "target") + }); + } + return [.. result]; + } + + private static void WriteRolePermissions( + Utf8JsonWriter writer, + RolePermission[]? rolePermissions) + { + if (rolePermissions is null || rolePermissions.Length == 0) + { + return; + } + writer.WritePropertyName("rolePermissions"); + writer.WriteStartArray(); + foreach (RolePermission permission in rolePermissions) + { + writer.WriteStartObject(); + if (permission.Permissions != 0) + { + writer.WriteNumber("permissions", permission.Permissions); + } + WriteString(writer, "roleId", permission.Value); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + private static RolePermission[]? ReadRolePermissions( + JsonElement element, + string name) + { + if (!element.TryGetProperty(name, out JsonElement permissions) || + permissions.ValueKind != JsonValueKind.Array) + { + return null; + } + var result = new List(); + foreach (JsonElement permission in permissions.EnumerateArray()) + { + var rolePermission = new RolePermission + { + Value = GetString(permission, "roleId") + }; + if (TryGetUInt32(permission, "permissions", out uint value)) + { + rolePermission.Permissions = value; + } + result.Add(rolePermission); + } + return [.. result]; + } + + private static void WriteTexts( + Utf8JsonWriter writer, + string name, + Opc.Ua.Export.LocalizedText[]? texts) + { + if (texts is null || texts.Length == 0) + { + return; + } + writer.WritePropertyName(name); + writer.WriteStartArray(); + foreach (Opc.Ua.Export.LocalizedText text in texts) + { + writer.WriteStartObject(); + WriteText(writer, text); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + private static void WriteText( + Utf8JsonWriter writer, + Opc.Ua.Export.LocalizedText text) + { + WriteString(writer, "locale", text.Locale); + WriteString(writer, "value", text.Value); + } + + private static Opc.Ua.Export.LocalizedText[]? ReadTexts( + JsonElement element, + string name) + { + if (!element.TryGetProperty(name, out JsonElement texts) || + texts.ValueKind != JsonValueKind.Array) + { + return null; + } + var result = new List(); + foreach (JsonElement text in texts.EnumerateArray()) + { + result.Add(ReadText(text)); + } + return [.. result]; + } + + private static Opc.Ua.Export.LocalizedText ReadText(JsonElement element) + { + return new Opc.Ua.Export.LocalizedText + { + Locale = GetString(element, "locale") ?? string.Empty, + Value = GetString(element, "value") + }; + } + + private static void WriteXmlElements( + Utf8JsonWriter writer, + string name, + System.Xml.XmlElement[]? elements) + { + if (elements is null || elements.Length == 0) + { + return; + } + writer.WritePropertyName(name); + writer.WriteStartArray(); + foreach (System.Xml.XmlElement element in elements) + { + writer.WriteStringValue(element.OuterXml); + } + writer.WriteEndArray(); + } + + private static System.Xml.XmlElement[]? ReadXmlElements( + JsonElement element, + string name, + string pointer, + List diagnostics) + { + if (!element.TryGetProperty(name, out JsonElement array) || + array.ValueKind != JsonValueKind.Array) + { + return null; + } + var result = new List(); + int index = 0; + foreach (JsonElement item in array.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + System.Xml.XmlElement? parsed = ParseXml( + item.GetString(), + pointer + "/" + index.ToString(CultureInfo.InvariantCulture), + diagnostics); + if (parsed is not null) + { + result.Add(parsed); + } + } + index++; + } + return [.. result]; + } + + private static void WriteXmlElement( + Utf8JsonWriter writer, + string name, + System.Xml.XmlElement? element) + { + if (element is not null) + { + writer.WriteString(name, element.OuterXml); + } + } + + private static System.Xml.XmlElement? ReadXmlElement( + JsonElement element, + string name, + string pointer, + List diagnostics) + { + return ParseXml(GetString(element, name), pointer, diagnostics); + } + + private static System.Xml.XmlElement? ParseXml( + string? xml, + string pointer, + List diagnostics) + { + if (string.IsNullOrEmpty(xml)) + { + return null; + } + try + { + var document = new XmlDocument { XmlResolver = null }; + using var reader = XmlReader.Create( + new StringReader(xml), + CoreUtils.DefaultXmlReaderSettings()); + document.Load(reader); + return document.DocumentElement; + } + catch (XmlException ex) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NativeProjectionInvalid, + $"The native XML fragment is malformed: {ex.Message}", + WotLocation.FromPointer(pointer))); + return null; + } + } + + private static void WriteStrings( + Utf8JsonWriter writer, + string name, + string[]? values) + { + if (values is null || values.Length == 0) + { + return; + } + writer.WritePropertyName(name); + writer.WriteStartArray(); + foreach (string value in values) + { + writer.WriteStringValue(value); + } + writer.WriteEndArray(); + } + + private static string[]? ReadStrings(JsonElement element, string name) + { + if (!element.TryGetProperty(name, out JsonElement values) || + values.ValueKind != JsonValueKind.Array) + { + return null; + } + var result = new List(); + foreach (JsonElement value in values.EnumerateArray()) + { + if (value.ValueKind == JsonValueKind.String) + { + result.Add(value.GetString()!); + } + } + return [.. result]; + } + + private static void WriteString( + Utf8JsonWriter writer, + string name, + string? value) + { + if (!string.IsNullOrEmpty(value)) + { + writer.WriteString(name, value); + } + } + + 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 bool GetBoolean(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(name, out JsonElement value) && + value.ValueKind == JsonValueKind.True; + } + + private static bool TryGetByte( + JsonElement element, + string name, + out byte value) + { + value = default; + return element.TryGetProperty(name, out JsonElement number) && + number.ValueKind == JsonValueKind.Number && + number.TryGetByte(out value); + } + + private static bool TryGetUInt16( + JsonElement element, + string name, + out ushort value) + { + value = default; + return element.TryGetProperty(name, out JsonElement number) && + number.ValueKind == JsonValueKind.Number && + number.TryGetUInt16(out value); + } + + private static bool TryGetUInt32( + JsonElement element, + string name, + out uint value) + { + value = default; + return element.TryGetProperty(name, out JsonElement number) && + number.ValueKind == JsonValueKind.Number && + number.TryGetUInt32(out value); + } + + private static bool TryGetInt32( + JsonElement element, + string name, + out int value) + { + value = default; + return element.TryGetProperty(name, out JsonElement number) && + number.ValueKind == JsonValueKind.Number && + number.TryGetInt32(out value); + } + + private static bool TryGetDouble( + JsonElement element, + string name, + out double value) + { + value = default; + return element.TryGetProperty(name, out JsonElement number) && + number.ValueKind == JsonValueKind.Number && + number.TryGetDouble(out value); + } + + private static bool TryGetDate( + JsonElement element, + string name, + out DateTime value) + { + value = default; + string? text = GetString(element, name); + if (string.IsNullOrEmpty(text)) + { + return false; + } + try + { + value = XmlConvert.ToDateTime(text, XmlDateTimeSerializationMode.RoundtripKind); + return true; + } + catch (FormatException) + { + return false; + } + } + + private static string FormatDate(DateTime value) + { + return XmlConvert.ToString(value, XmlDateTimeSerializationMode.RoundtripKind); + } + + private static string GetNodeClass(UANode node) + { + return node switch + { + UAObjectType => "ObjectType", + UAVariableType => "VariableType", + UAReferenceType => "ReferenceType", + UADataType => "DataType", + UAView => "View", + UAMethod => "Method", + UAVariable => "Variable", + UAObject => "Object", + _ => "Unknown" + }; + } + + private static int CountErrors(List diagnostics) + { + int count = 0; + foreach (WotDiagnostic diagnostic in diagnostics) + { + if (diagnostic.Severity == WotDiagnosticSeverity.Error) + { + count++; + } + } + return count; + } + } +} diff --git a/src/Opc.Ua.Types/Wot/WotNodeSetConverter.Mapping.cs b/src/Opc.Ua.Types/Wot/WotNodeSetConverter.Mapping.cs new file mode 100644 index 0000000000..538f6c5589 --- /dev/null +++ b/src/Opc.Ua.Types/Wot/WotNodeSetConverter.Mapping.cs @@ -0,0 +1,2353 @@ +/* ======================================================================== + * 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.Text.Json; +using Opc.Ua.Export; + +namespace Opc.Ua.Wot +{ + /// + /// Native readable mapping (NodeSet2 to WoT affordances) and WoT to + /// NodeSet2 synthesis for the . + /// + public static partial class WotNodeSetConverter + { + private const uint AccessLevelCurrentRead = 1; + private const uint AccessLevelCurrentWrite = 2; + + private static readonly Dictionary s_dataTypeToJsonType = + new(StringComparer.Ordinal) + { + ["i=1"] = "boolean", + ["Boolean"] = "boolean", + ["i=2"] = "integer", + ["SByte"] = "integer", + ["i=3"] = "integer", + ["Byte"] = "integer", + ["i=4"] = "integer", + ["Int16"] = "integer", + ["i=5"] = "integer", + ["UInt16"] = "integer", + ["i=6"] = "integer", + ["Int32"] = "integer", + ["i=7"] = "integer", + ["UInt32"] = "integer", + ["i=8"] = "integer", + ["Int64"] = "integer", + ["i=9"] = "integer", + ["UInt64"] = "integer", + ["i=10"] = "number", + ["Float"] = "number", + ["i=11"] = "number", + ["Double"] = "number", + ["i=12"] = "string", + ["String"] = "string" + }; + + private static void WriteContext(Utf8JsonWriter writer, UANodeSet nodeSet) + { + writer.WritePropertyName("@context"); + writer.WriteStartArray(); + writer.WriteStringValue(WotVocabulary.WotContext); + writer.WriteStartObject(); + writer.WriteString("uav", WotVocabulary.VocabularyNamespace); + writer.WriteString("ua", WotVocabulary.OpcUaNamespace); + if (nodeSet.NamespaceUris is not null) + { + for (int ii = 0; ii < nodeSet.NamespaceUris.Length; ii++) + { + writer.WriteString( + "ns" + (ii + 1).ToString( + System.Globalization.CultureInfo.InvariantCulture), + nodeSet.NamespaceUris[ii]); + } + } + writer.WriteEndObject(); + writer.WriteEndArray(); + } + + private static void WriteRootType(Utf8JsonWriter writer, UANode? root, bool isEventType) + { + switch (root) + { + case UAObjectType when isEventType: + // An ObjectType derived from BaseEventType projects a UA + // EventType, annotated with uav:eventType (WoT Binding + // Section 5.2) rather than the generic uav:objectType. + writer.WritePropertyName("@type"); + writer.WriteStartArray(); + writer.WriteStringValue(WotVocabulary.ThingModelType); + writer.WriteStringValue(WotVocabulary.EventTypeAnnotation); + writer.WriteEndArray(); + break; + case UAObjectType: + writer.WritePropertyName("@type"); + writer.WriteStartArray(); + writer.WriteStringValue(WotVocabulary.ThingModelType); + writer.WriteStringValue("uav:objectType"); + writer.WriteEndArray(); + break; + case UAVariableType: + writer.WritePropertyName("@type"); + writer.WriteStartArray(); + writer.WriteStringValue(WotVocabulary.ThingModelType); + writer.WriteStringValue("uav:variableType"); + writer.WriteEndArray(); + break; + case UAObject: + writer.WriteString("@type", "uav:object"); + break; + case UAVariable: + writer.WriteString("@type", "uav:variable"); + break; + default: + writer.WriteString("@type", WotVocabulary.ThingModelType); + break; + } + } + + private static void WriteDescription(Utf8JsonWriter writer, Opc.Ua.Export.LocalizedText[]? description) + { + string? text = FirstText(description); + if (!string.IsNullOrEmpty(text)) + { + writer.WriteString("description", text); + } + } + + private static void WriteAffordances( + Utf8JsonWriter writer, + UANodeSet nodeSet, + UANode? root, + List diagnostics, + WotNodeSetConverterOptions options) + { + if (root?.References is null) + { + return; + } + + Dictionary index = BuildIndex(nodeSet); + string[]? namespaceUris = nodeSet.NamespaceUris; + var properties = new List(); + var actions = new List(); + var events = new List(); + + // HasComponent subtypes (for example HasOrderedComponent) are + // surfaced for discovery under uav:hasComponent / uav:componentOf and + // additionally pinned by a link whose rel is the semantic + // ReferenceType model name and whose uav:refId is the definitive + // ExpandedNodeId (WoT Binding Sections 5.1.2 and 5.3). + var componentChildren = new List(); + var componentParents = new List(); + var typedComponentLinks = new List<( + string Target, + string Rel, + string RefType, + string RefName)>(); + + foreach (Reference reference in root.References) + { + if (reference.Value is null) + { + continue; + } + if (reference.IsForward && IsComponentReference(reference.ReferenceType)) + { + if (index.TryGetValue(reference.Value, out UANode? target)) + { + if (target is UAVariable variable) + { + properties.Add(variable); + } + else if (target is UAMethod method) + { + actions.Add(method); + } + } + } + else if (reference.IsForward && + IsGeneratesEventReference(reference.ReferenceType) && + index.TryGetValue(reference.Value, out UANode? eventType)) + { + events.Add(eventType); + } + else if (WotVocabulary.TryGetHasComponentSubtype( + reference.ReferenceType, out string subtypeNodeId)) + { + string? portableTarget = ToPortableNodeId(reference.Value, namespaceUris); + if (string.IsNullOrEmpty(portableTarget)) + { + continue; + } + (reference.IsForward ? componentChildren : componentParents) + .Add(portableTarget!); + typedComponentLinks.Add(( + portableTarget!, + ToReferenceTypeModelName(reference.ReferenceType, index) + ?? "ua:HasOrderedComponent", + subtypeNodeId, + ComponentRefName(reference.Value, index))); + } + } + + bool isThingModel = root is UAObjectType or UAVariableType; + int affordanceCount = 0; + + WriteComponentArray(writer, "uav:hasComponent", componentChildren); + WriteComponentArray(writer, "uav:componentOf", componentParents); + + if (properties.Count > 0) + { + writer.WritePropertyName("properties"); + writer.WriteStartObject(); + var used = new HashSet(StringComparer.Ordinal); + foreach (UAVariable variable in properties) + { + if (!CheckAffordanceBudget(ref affordanceCount, options, diagnostics)) + { + break; + } + writer.WritePropertyName(UniqueKey(LocalName(variable.BrowseName), used)); + WriteVariableAffordance(writer, variable, isThingModel, namespaceUris); + } + writer.WriteEndObject(); + } + + if (actions.Count > 0) + { + writer.WritePropertyName("actions"); + writer.WriteStartObject(); + var used = new HashSet(StringComparer.Ordinal); + foreach (UAMethod method in actions) + { + if (!CheckAffordanceBudget(ref affordanceCount, options, diagnostics)) + { + break; + } + writer.WritePropertyName(UniqueKey(LocalName(method.BrowseName), used)); + WriteMethodAffordance(writer, method, namespaceUris); + } + writer.WriteEndObject(); + } + + if (events.Count > 0) + { + writer.WritePropertyName("events"); + writer.WriteStartObject(); + var used = new HashSet(StringComparer.Ordinal); + foreach (UANode eventType in events) + { + if (!CheckAffordanceBudget(ref affordanceCount, options, diagnostics)) + { + break; + } + writer.WritePropertyName(UniqueKey(LocalName(eventType.BrowseName), used)); + WriteEventAffordance(writer, eventType, namespaceUris); + } + writer.WriteEndObject(); + } + + WriteTypedComponentLinks(writer, typedComponentLinks); + } + + private static void WriteComponentArray( + Utf8JsonWriter writer, + string name, + List targets) + { + if (targets.Count == 0) + { + return; + } + writer.WritePropertyName(name); + writer.WriteStartArray(); + foreach (string target in targets) + { + writer.WriteStringValue(target); + } + writer.WriteEndArray(); + } + + private static void WriteTypedComponentLinks( + Utf8JsonWriter writer, + List<( + string Target, + string Rel, + string RefType, + string RefName)> links) + { + if (links.Count == 0) + { + return; + } + writer.WritePropertyName("links"); + writer.WriteStartArray(); + foreach (( + string target, + string rel, + string refType, + string refName) in links) + { + writer.WriteStartObject(); + writer.WriteString("rel", rel); + writer.WriteString("href", target); + writer.WriteString("uav:refId", refType); + writer.WriteString("uav:refName", refName); + writer.WriteEndObject(); + } + writer.WriteEndArray(); + } + + private static string ComponentRefName(string rawTarget, Dictionary index) + { + if (index.TryGetValue(rawTarget, out UANode? node) && + LocalName(node.BrowseName) is { Length: > 0 } local) + { + return local; + } + return rawTarget; + } + + private static string? ToReferenceTypeModelName( + string? referenceType, + Dictionary index) + { + if (WotVocabulary.TryGetReferenceTypeBrowseName( + referenceType, + out string browseName)) + { + return "ua:" + browseName; + } + if (referenceType is not null && + index.TryGetValue(referenceType, out UANode? node) && + node is UAReferenceType referenceTypeNode) + { + return ToCompactModelName(referenceTypeNode.BrowseName); + } + return ToCompactModelName(referenceType); + } + + private static string? ToCompactModelName(string? qualifiedBrowseName) + { + if (string.IsNullOrEmpty(qualifiedBrowseName)) + { + return null; + } + int separator = -1; + for (int ii = 0; ii < qualifiedBrowseName!.Length; ii++) + { + if (qualifiedBrowseName[ii] == ':') + { + separator = ii; + break; + } + } + if (separator <= 0 || separator + 1 >= qualifiedBrowseName.Length) + { + return null; + } + int namespaceIndex = 0; + for (int ii = 0; ii < separator; ii++) + { + char character = qualifiedBrowseName[ii]; + if (!char.IsDigit(character) || + namespaceIndex > (int.MaxValue - (character - '0')) / 10) + { + return null; + } + namespaceIndex = (namespaceIndex * 10) + (character - '0'); + } + string prefix = namespaceIndex == 0 + ? "ua" + : "ns" + namespaceIndex.ToString( + System.Globalization.CultureInfo.InvariantCulture); + var builder = new System.Text.StringBuilder( + prefix.Length + qualifiedBrowseName.Length - separator); + builder.Append(prefix); + builder.Append( + qualifiedBrowseName, + separator, + qualifiedBrowseName.Length - separator); + return builder.ToString(); + } + + private static void WriteVariableAffordance( + Utf8JsonWriter writer, + UAVariable variable, + bool isThingModel, + string[]? namespaceUris) + { + writer.WriteStartObject(); + writer.WriteString("@type", isThingModel ? "uav:variableType" : "uav:variable"); + WriteOptional(writer, "title", FirstText(variable.DisplayName)); + WriteDescription(writer, variable.Description); + WriteOptional( + writer, + "uav:browseName", + ToPortableQualifiedName(variable.BrowseName, namespaceUris)); + WriteOptional(writer, "uav:id", ToPortableNodeId(variable.NodeId, namespaceUris)); + + string? jsonType = MapDataTypeToJson(variable.DataType); + if (jsonType is not null) + { + writer.WriteString("type", jsonType); + } + + bool readable = (variable.AccessLevel & AccessLevelCurrentRead) != 0; + bool writable = (variable.AccessLevel & AccessLevelCurrentWrite) != 0; + if (readable && !writable) + { + writer.WriteBoolean("readOnly", true); + } + else if (writable && !readable) + { + writer.WriteBoolean("writeOnly", true); + } + if (readable) + { + // This advertises observation through the WoT binding. It does + // not define core UA monitorability; any Variable may be a + // MonitoredItem when the Server grants access. + writer.WriteBoolean("observable", true); + } + + WriteModellingRule(writer, variable); + writer.WriteEndObject(); + } + + private static void WriteMethodAffordance( + Utf8JsonWriter writer, + UAMethod method, + string[]? namespaceUris) + { + writer.WriteStartObject(); + writer.WriteString("@type", "uav:method"); + WriteOptional(writer, "title", FirstText(method.DisplayName)); + WriteDescription(writer, method.Description); + WriteOptional( + writer, + "uav:browseName", + ToPortableQualifiedName(method.BrowseName, namespaceUris)); + WriteOptional(writer, "uav:id", ToPortableNodeId(method.NodeId, namespaceUris)); + WriteModellingRule(writer, method); + writer.WriteEndObject(); + } + + private static void WriteEventAffordance( + Utf8JsonWriter writer, + UANode eventType, + string[]? namespaceUris) + { + writer.WriteStartObject(); + // uav:eventType is the @type annotation counterpart of the uav:isEvent + // flag; an EventType projection carries both (WoT Binding Section 5.2). + writer.WriteString("@type", WotVocabulary.EventTypeAnnotation); + WriteOptional(writer, "title", FirstText(eventType.DisplayName)); + WriteDescription(writer, eventType.Description); + writer.WriteBoolean("uav:isEvent", true); + WriteOptional( + writer, + "uav:browseName", + ToPortableQualifiedName(eventType.BrowseName, namespaceUris)); + WriteOptional(writer, "uav:id", ToPortableNodeId(eventType.NodeId, namespaceUris)); + WriteModellingRule(writer, eventType); + writer.WriteEndObject(); + } + + private static void WriteModellingRule(Utf8JsonWriter writer, UANode node) + { + string? rule = GetBaselineModellingRule(node); + if (rule is not null) + { + writer.WriteString("uav:modellingRule", rule); + } + } + + private static UANodeSet? Synthesize( + WotDocument document, + WotNodeSetConverterOptions options, + IWotThingResolver? thingResolver, + WotResolutionContext resolutionContext, + List diagnostics) + { + WotDocumentKind kind = document.Kind; + if (kind == WotDocumentKind.Unknown) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NoConvertibleContent, + "The document is neither a Thing Model nor a Thing Description and carries no preservation envelope or native projection.")); + return null; + } + + bool isThingModel = kind == WotDocumentKind.ThingModel; + bool isEventType = isThingModel && HasEventTypeAnnotation(document); + + // Portable identity and event-annotation validation (WoT Binding + // Sections 5.1.1 and 5.2). Runs before synthesis so a document that + // uses the session-local ns= form or contradicts itself is + // diagnosed; the exact uav:nodeSet envelope and uav:nodes projection + // are never reached here and keep their own namespace indices. + ValidatePortableIdentity(document, diagnostics); + ValidateEventAnnotations(document, diagnostics); + ValidateModelConceptNames(document, diagnostics); + + string modelUri = DeriveModelUri(document); + string rootLocal = LocalName(GetUavString(document, "browseName")) ?? + SanitizeName(document.Title) ?? "Thing"; + string? authoredRootId = GetUavString(document, "id"); + string rootNodeId = GenerateNodeId(rootLocal); + + var nodeSet = new UANodeSet + { + NamespaceUris = [modelUri], + Models = + [ + new ModelTableEntry { ModelUri = modelUri } + ] + }; + if (authoredRootId is not null) + { + rootNodeId = ToNodeSetNodeId(authoredRootId, nodeSet, diagnostics); + } + else + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Info, + WotDiagnosticCode.GeneratedNodeId, + "NodeIds were generated deterministically from the target namespace and browse paths.", + new WotLocation(nodeId: rootNodeId))); + } + + var items = new List(); + var rootReferences = new List(); + + UANode rootNode; + if (isThingModel) + { + rootNode = new UAObjectType { IsAbstract = false }; + rootReferences.Add(new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + // An event-type Thing Model (@type uav:eventType) derives + // from BaseEventType rather than BaseObjectType. + Value = isEventType + ? WotVocabulary.BaseEventType + : WotVocabulary.BaseObjectType + }); + } + else + { + rootNode = new UAObject(); + rootReferences.Add(new Reference + { + ReferenceType = "HasTypeDefinition", + IsForward = true, + Value = WotVocabulary.BaseObjectType + }); + } + + rootNode.NodeId = rootNodeId; + string? rootBrowseName = GetUavString(document, "browseName"); + rootNode.BrowseName = rootBrowseName is null + ? "1:" + rootLocal + : ToNodeSetQualifiedName( + document, + rootBrowseName, + nodeSet, + diagnostics); + rootNode.DisplayName = MakeText(document.Title ?? rootLocal); + string? rootDescription = GetRootString(document, "description"); + if (rootDescription is not null) + { + rootNode.Description = MakeText(rootDescription); + } + + int affordanceCount = 0; + + foreach (KeyValuePair property in document.Properties) + { + if (!CheckAffordanceBudget(ref affordanceCount, options, diagnostics)) + { + break; + } + SynthesizeProperty( + document, nodeSet, property.Key, property.Value, rootLocal, + rootNodeId, isThingModel, + items, rootReferences, diagnostics); + } + + foreach (KeyValuePair action in document.Actions) + { + if (!CheckAffordanceBudget(ref affordanceCount, options, diagnostics)) + { + break; + } + SynthesizeAction( + document, nodeSet, action.Key, action.Value, rootLocal, + rootNodeId, items, rootReferences, diagnostics); + } + + foreach (KeyValuePair eventAffordance in document.Events) + { + if (!CheckAffordanceBudget(ref affordanceCount, options, diagnostics)) + { + break; + } + SynthesizeEvent( + document, nodeSet, eventAffordance.Key, eventAffordance.Value, + rootLocal, items, rootReferences, diagnostics); + } + + // A ReferenceType relation whose target is also listed under + // uav:hasComponent / uav:componentOf pins the exact subtype of that + // component (WoT Binding Section 5.3). Collect those pins once so the + // link pass does not also emit a separate generic reference and the + // component pass recreates the exact ReferenceType. + Dictionary componentTypedRefs = + CollectComponentTypedRefs(document, diagnostics); + SynthesizeLinks( + document, rootReferences, componentTypedRefs, thingResolver, + resolutionContext, options, diagnostics); + SynthesizeComponentArrays(document, rootReferences, componentTypedRefs); + + rootNode.References = [.. rootReferences]; + items.Insert(0, rootNode); + nodeSet.Items = [.. items]; + return nodeSet; + } + + private static void SynthesizeProperty( + WotDocument document, + UANodeSet nodeSet, + string key, + JsonElement schema, + string rootLocal, + string rootNodeId, + bool isThingModel, + List items, + List rootReferences, + List diagnostics) + { + string local = LocalName(GetElementString(schema, "uav:browseName")) ?? key; + string? authoredNodeId = GetElementString(schema, "uav:id"); + string nodeId = authoredNodeId is null + ? GenerateNodeId(rootLocal + "/" + local) + : ToNodeSetNodeId(authoredNodeId, nodeSet, diagnostics); + string? authoredBrowseName = GetElementString(schema, "uav:browseName"); + var variable = new UAVariable + { + NodeId = nodeId, + BrowseName = authoredBrowseName is null + ? "1:" + local + : ToNodeSetQualifiedName( + document, + authoredBrowseName, + nodeSet, + diagnostics), + ParentNodeId = rootNodeId, + DataType = MapJsonSchemaToDataType(schema), + AccessLevel = MapAccessLevel(schema) + }; + string? title = GetElementString(schema, "title"); + if (title is not null) + { + variable.DisplayName = MakeText(title); + } + string? description = GetElementString(schema, "description"); + if (description is not null) + { + variable.Description = MakeText(description); + } + + var references = new List + { + new Reference + { + ReferenceType = "HasTypeDefinition", + IsForward = true, + Value = WotVocabulary.BaseDataVariableType + }, + new Reference + { + ReferenceType = "HasComponent", + IsForward = false, + Value = rootNodeId + } + }; + AddModellingRule(schema, references); + variable.References = [.. references]; + + ReportUnsupportedSchema(schema, nodeId, diagnostics); + + items.Add(variable); + rootReferences.Add(new Reference + { + ReferenceType = "HasComponent", + IsForward = true, + Value = nodeId + }); + _ = isThingModel; + } + + private static void SynthesizeAction( + WotDocument document, + UANodeSet nodeSet, + string key, + JsonElement action, + string rootLocal, + string rootNodeId, + List items, + List rootReferences, + List diagnostics) + { + string local = LocalName(GetElementString(action, "uav:browseName")) ?? key; + string? authoredNodeId = GetElementString(action, "uav:id"); + string nodeId = authoredNodeId is null + ? GenerateNodeId(rootLocal + "/" + local) + : ToNodeSetNodeId(authoredNodeId, nodeSet, diagnostics); + string? authoredBrowseName = GetElementString(action, "uav:browseName"); + var method = new UAMethod + { + NodeId = nodeId, + BrowseName = authoredBrowseName is null + ? "1:" + local + : ToNodeSetQualifiedName( + document, + authoredBrowseName, + nodeSet, + diagnostics), + ParentNodeId = rootNodeId + }; + string? title = GetElementString(action, "title"); + if (title is not null) + { + method.DisplayName = MakeText(title); + } + + var references = new List + { + new Reference + { + ReferenceType = "HasComponent", + IsForward = false, + Value = rootNodeId + } + }; + AddModellingRule(action, references); + method.References = [.. references]; + + items.Add(method); + rootReferences.Add(new Reference + { + ReferenceType = "HasComponent", + IsForward = true, + Value = nodeId + }); + } + + private static void SynthesizeEvent( + WotDocument document, + UANodeSet nodeSet, + string key, + JsonElement eventAffordance, + string rootLocal, + List items, + List rootReferences, + List diagnostics) + { + string local = LocalName(GetElementString(eventAffordance, "uav:browseName")) ?? key; + string? authoredNodeId = GetElementString( + eventAffordance, + "uav:id"); + string nodeId = authoredNodeId is null + ? GenerateNodeId(rootLocal + "/" + local) + : ToNodeSetNodeId(authoredNodeId, nodeSet, diagnostics); + string? authoredBrowseName = GetElementString( + eventAffordance, + "uav:browseName"); + var eventType = new UAObjectType + { + NodeId = nodeId, + BrowseName = authoredBrowseName is null + ? "1:" + local + : ToNodeSetQualifiedName( + document, + authoredBrowseName, + nodeSet, + diagnostics), + IsAbstract = false + }; + string? title = GetElementString(eventAffordance, "title"); + if (title is not null) + { + eventType.DisplayName = MakeText(title); + } + eventType.References = + [ + new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + Value = WotVocabulary.BaseEventType + } + ]; + + items.Add(eventType); + rootReferences.Add(new Reference + { + ReferenceType = "GeneratesEvent", + IsForward = true, + Value = nodeId + }); + } + + private static void SynthesizeLinks( + WotDocument document, + List rootReferences, + Dictionary componentTypedRefs, + IWotThingResolver? thingResolver, + WotResolutionContext resolutionContext, + WotNodeSetConverterOptions options, + List diagnostics) + { + foreach (JsonElement link in document.Links) + { + string? rel = GetElementString(link, "rel"); + string? href = GetElementString(link, "href"); + if (rel is null || href is null) + { + continue; + } + + if (string.Equals(rel, "tm:extends", StringComparison.Ordinal)) + { + if (TryResolveTargetNodeId( + href, thingResolver, resolutionContext, options, diagnostics, out string extendsTarget)) + { + SetSuperType(rootReferences, extendsTarget); + } + continue; + } + + if (!IsReferenceRel(document, link, rel)) + { + continue; + } + + // A typed link that pins the subtype of a listed component is + // realized by the component pass, not here, so the component is + // not emitted twice (WoT Binding Section 5.3). + if (componentTypedRefs.ContainsKey(href)) + { + continue; + } + + if (!TryResolveLinkReferenceType( + document, + link, + rel, + diagnostics, + out string referenceType)) + { + continue; + } + if (TryResolveTargetNodeId( + href, thingResolver, resolutionContext, options, diagnostics, out string linkTarget)) + { + rootReferences.Add(new Reference + { + ReferenceType = referenceType, + IsForward = true, + Value = linkTarget + }); + } + } + } + + private static bool TryResolveLinkReferenceType( + WotDocument document, + JsonElement link, + string rel, + List diagnostics, + out string referenceType) + { + string? modelName = IsModelConceptRelation(document, link, rel) + ? rel + : null; + string? definitive = GetElementString(link, "uav:refId"); + string? canonicalDefinitive = CanonicalReferenceType(definitive); + if (definitive is not null && canonicalDefinitive is null) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptUnresolved, + $"The uav:refId value '{definitive}' is not a portable " + + "ExpandedNodeId.", + new WotLocation(reference: definitive))); + referenceType = string.Empty; + return false; + } + + if (modelName is not null && + TryResolveReferenceTypeName( + document, + modelName, + out string resolvedName)) + { + if (canonicalDefinitive is not null && + !string.Equals( + resolvedName, + canonicalDefinitive, + StringComparison.Ordinal)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptConflict, + $"The ReferenceType model name '{modelName}' resolves to " + + $"'{resolvedName}' but uav:refId is '{definitive}'.", + new WotLocation(reference: modelName))); + referenceType = string.Empty; + return false; + } + referenceType = resolvedName; + return true; + } + + if (canonicalDefinitive is not null) + { + referenceType = canonicalDefinitive; + return true; + } + + if (modelName is not null) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptUnresolved, + $"The ReferenceType relation '{modelName}' could not be " + + "resolved and has no ExpandedNodeId fallback.", + new WotLocation(reference: modelName))); + referenceType = string.Empty; + return false; + } + + referenceType = DefaultReferenceType(rel); + return true; + } + + private static string? CanonicalReferenceType(string? referenceType) + { + if (string.IsNullOrEmpty(referenceType)) + { + return null; + } + return IsNodeId(referenceType) ? referenceType : null; + } + + private static bool TryResolveReferenceTypeName( + WotDocument document, + string modelName, + out string referenceType) + { + referenceType = string.Empty; + if (!TrySplitCompactModelName( + modelName, + out string prefix, + out string browseName) || + !TryGetContextNamespace(document, prefix, out string namespaceUri)) + { + return false; + } + return string.Equals( + namespaceUri, + WotVocabulary.OpcUaNamespace, + StringComparison.Ordinal) && + WotVocabulary.TryGetReferenceTypeNodeId( + browseName, + out referenceType); + } + + private static bool TrySplitCompactModelName( + string value, + out string prefix, + out string browseName) + { + prefix = string.Empty; + browseName = string.Empty; + int separator = -1; + for (int ii = 0; ii < value.Length; ii++) + { + if (value[ii] == ':') + { + separator = ii; + break; + } + } + if (separator <= 0 || separator + 1 >= value.Length) + { + return false; + } + string candidate = value.Substring(0, separator); + if (!IsAsciiLetter(candidate[0]) && candidate[0] != '_') + { + return false; + } + for (int ii = 0; ii < candidate.Length; ii++) + { + char character = candidate[ii]; + if (!IsAsciiLetter(character) && + character is not (>= '0' and <= '9') && + character is not ('_' or '.' or '-')) + { + return false; + } + } + prefix = candidate; + browseName = value.Substring(separator + 1); + return true; + } + + private static bool IsAsciiLetter(char value) + { + return value is >= 'A' and <= 'Z' or >= 'a' and <= 'z'; + } + + private static bool TryGetContextNamespace( + WotDocument document, + string prefix, + out string namespaceUri) + { + if (string.Equals(prefix, "ua", StringComparison.Ordinal)) + { + namespaceUri = WotVocabulary.OpcUaNamespace; + return true; + } + if (string.Equals(prefix, "uav", StringComparison.Ordinal)) + { + namespaceUri = WotVocabulary.VocabularyNamespace; + return true; + } + if (document.TryGetContext(out JsonElement context) && + TryGetContextNamespace(context, prefix, out namespaceUri)) + { + return true; + } + namespaceUri = string.Empty; + return false; + } + + private static bool TryGetContextNamespace( + JsonElement context, + string prefix, + out string namespaceUri) + { + if (context.ValueKind == JsonValueKind.Object && + context.TryGetProperty(prefix, out JsonElement value) && + value.ValueKind == JsonValueKind.String) + { + namespaceUri = value.GetString()!; + return true; + } + if (context.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in context.EnumerateArray()) + { + if (TryGetContextNamespace(entry, prefix, out namespaceUri)) + { + return true; + } + } + } + namespaceUri = string.Empty; + return false; + } + + /// + /// Collects the subtype pins carried by ReferenceType model-name links + /// whose target is also listed under uav:hasComponent or + /// uav:componentOf: target ExpandedNodeId to the exact + /// ReferenceType named by rel and, when needed, + /// uav:refId + /// (WoT Binding Section 5.3). + /// + private static Dictionary CollectComponentTypedRefs( + WotDocument document, + List diagnostics) + { + var pins = new Dictionary(StringComparer.Ordinal); + var componentTargets = new HashSet(StringComparer.Ordinal); + CollectComponentTargets(document, "hasComponent", componentTargets); + CollectComponentTargets(document, "componentOf", componentTargets); + if (componentTargets.Count == 0) + { + return pins; + } + foreach (JsonElement link in document.Links) + { + string? rel = GetElementString(link, "rel"); + if (rel is null || + !IsModelConceptRelation(document, link, rel)) + { + continue; + } + string? href = GetElementString(link, "href"); + if (href is not null && + componentTargets.Contains(href) && + TryResolveLinkReferenceType( + document, + link, + rel, + diagnostics, + out string refType)) + { + pins[href] = refType; + } + } + return pins; + } + + private static void CollectComponentTargets( + WotDocument document, + string localName, + HashSet targets) + { + if (document.TryGetUav(localName, out JsonElement array) && + array.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement target in array.EnumerateArray()) + { + if (target.ValueKind == JsonValueKind.String && + target.GetString() is { Length: > 0 } value) + { + targets.Add(value); + } + } + } + } + + private static void SynthesizeComponentArrays( + WotDocument document, + List rootReferences, + Dictionary componentTypedRefs) + { + if (document.TryGetUav("hasComponent", out JsonElement hasComponent) && + hasComponent.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement target in hasComponent.EnumerateArray()) + { + if (target.ValueKind == JsonValueKind.String) + { + rootReferences.Add(new Reference + { + ReferenceType = ComponentReferenceType(target.GetString(), componentTypedRefs), + IsForward = true, + Value = target.GetString() + }); + } + } + } + if (document.TryGetUav("componentOf", out JsonElement componentOf) && + componentOf.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement target in componentOf.EnumerateArray()) + { + if (target.ValueKind == JsonValueKind.String) + { + rootReferences.Add(new Reference + { + ReferenceType = ComponentReferenceType(target.GetString(), componentTypedRefs), + IsForward = false, + Value = target.GetString() + }); + } + } + } + } + + private static string ComponentReferenceType( + string? target, + Dictionary componentTypedRefs) + { + // A component whose exact subtype is pinned by a matching + // ReferenceType link is recreated with that ReferenceType; + // otherwise plain HasComponent is used (WoT Binding Section 5.3). + if (target is not null && componentTypedRefs.TryGetValue(target, out string? refType)) + { + return refType; + } + return "HasComponent"; + } + + private static bool TryResolveTargetNodeId( + string reference, + IWotThingResolver? resolver, + WotResolutionContext context, + WotNodeSetConverterOptions options, + List diagnostics, + out string nodeId) + { + if (IsNodeId(reference)) + { + nodeId = reference; + return true; + } + nodeId = string.Empty; + if (resolver is null) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.UnresolvedReference, + $"The reference '{reference}' could not be resolved to a NodeId without an external resolver.", + new WotLocation(reference: reference))); + return false; + } + + var entered = new List(); + try + { + string current = reference; + while (true) + { + if (!context.TryEnter(WotResolutionKind.Thing, current, out WotDiagnostic? blocking)) + { + diagnostics.Add(blocking!); + return false; + } + entered.Add(current); + + WotResolverResult result = resolver.ResolveThing(current, context); + if (!result.Found) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.ResolverNotFound, + $"The referenced document '{current}' could not be resolved.", + new WotLocation(reference: current))); + return false; + } + if (!context.TryAddBytes(current, result.Content.Length, out WotDiagnostic? limit)) + { + diagnostics.Add(limit!); + return false; + } + + using WotDocument resolved = WotDocument.Parse(result.Content, options); + string? resolvedId = GetUavString(resolved, "id"); + if (resolvedId is not null) + { + nodeId = resolvedId; + return true; + } + string? congruent = GetUavString(resolved, "congruentType"); + if (congruent is not null && + !string.Equals(congruent, current, StringComparison.Ordinal)) + { + current = congruent; + continue; + } + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.UnresolvedReference, + $"The referenced document '{current}' does not declare a uav:id.", + new WotLocation(reference: current))); + return false; + } + } + finally + { + for (int ii = entered.Count - 1; ii >= 0; ii--) + { + context.Leave(entered[ii]); + } + } + } + + private static void ReportUnsupportedSchema( + JsonElement schema, + string nodeId, + List diagnostics) + { + if (schema.TryGetProperty("uav:externalSchema", out JsonElement external) && + external.ValueKind == JsonValueKind.String) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.UnsupportedSchema, + $"The property references an external schema '{external.GetString()}' that was not inlined.", + WotLocation.FromNode(nodeId))); + return; + } + string? type = GetElementString(schema, "type"); + if (string.Equals(type, "object", StringComparison.Ordinal) || + string.Equals(type, "array", StringComparison.Ordinal)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.UnsupportedSchema, + $"The '{type}' DataSchema was mapped to a generic DataType; a custom DataType may be required.", + WotLocation.FromNode(nodeId))); + } + } + + private static void AddModellingRule(JsonElement schema, List references) + { + string? rule = GetElementString(schema, "uav:modellingRule"); + if (rule is not null && WotVocabulary.TryGetModellingRuleNodeId(rule, out string ruleNodeId)) + { + references.Add(new Reference + { + ReferenceType = "HasModellingRule", + IsForward = true, + Value = ruleNodeId + }); + } + } + + private static void SetSuperType(List references, string target) + { + for (int ii = 0; ii < references.Count; ii++) + { + if (string.Equals(references[ii].ReferenceType, "HasSubtype", StringComparison.Ordinal) && + !references[ii].IsForward) + { + references[ii].Value = target; + return; + } + } + references.Add(new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + Value = target + }); + } + + private static Dictionary BuildIndex(UANodeSet nodeSet) + { + var index = new Dictionary(StringComparer.Ordinal); + if (nodeSet.Items is not null) + { + foreach (UANode node in nodeSet.Items) + { + if (!string.IsNullOrEmpty(node.NodeId)) + { + index[node.NodeId!] = node; + } + } + } + return index; + } + + private static UANode? SelectRootNode(UANodeSet nodeSet) + { + if (nodeSet.Items is null || nodeSet.Items.Length == 0) + { + return null; + } + return FirstOf(nodeSet) + ?? FirstOf(nodeSet) + ?? FirstOf(nodeSet) + ?? FirstOf(nodeSet) + ?? nodeSet.Items[0]; + } + + private static UANode? FirstOf(UANodeSet nodeSet) where T : UANode + { + foreach (UANode node in nodeSet.Items!) + { + if (node is T) + { + return node; + } + } + return null; + } + + private static bool CheckAffordanceBudget( + ref int count, + WotNodeSetConverterOptions options, + List diagnostics) + { + if (count >= options.MaxAffordanceCount) + { + if (count == options.MaxAffordanceCount) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.AffordanceCountExceeded, + $"The affordance count exceeded the configured limit of {options.MaxAffordanceCount}.")); + count++; + } + return false; + } + count++; + return true; + } + + private static bool IsComponentReference(string? referenceType) + { + return string.Equals(referenceType, "HasComponent", StringComparison.Ordinal) || + string.Equals(referenceType, "HasProperty", StringComparison.Ordinal) || + string.Equals(referenceType, WotVocabulary.HasComponent, StringComparison.Ordinal) || + string.Equals(referenceType, WotVocabulary.HasProperty, StringComparison.Ordinal); + } + + private static bool IsGeneratesEventReference(string? referenceType) + { + return string.Equals(referenceType, "GeneratesEvent", StringComparison.Ordinal) || + string.Equals(referenceType, WotVocabulary.GeneratesEvent, StringComparison.Ordinal); + } + + private static bool IsReferenceRel( + WotDocument document, + JsonElement link, + string rel) + { + return rel is "uav:reference" or + "uav:componentModel" or + "uav:capability" || + IsModelConceptRelation(document, link, rel); + } + + private static bool IsModelConceptRelation( + WotDocument document, + JsonElement link, + string rel) + { + if (!TrySplitCompactModelName( + rel, + out string prefix, + out _) || + string.Equals(prefix, "uav", StringComparison.Ordinal) || + string.Equals(prefix, "tm", StringComparison.Ordinal) || + IsExternalRelationPrefix(prefix) || + !IsModelConceptCandidate(link, prefix) || + !TryGetContextNamespace(document, prefix, out _)) + { + return false; + } + return true; + } + + private static bool IsKnownBindingRelation(string rel) + { + return rel is "uav:reference" or + "uav:componentModel" or + "uav:capability" or + "uav:componentOf"; + } + + private static bool IsModelConceptCandidate( + JsonElement link, + string prefix) + { + return string.Equals(prefix, "ua", StringComparison.Ordinal) || + StartsWithGeneratedNamespacePrefix(prefix) || + link.TryGetProperty("uav:refId", out _) || + link.TryGetProperty("uav:refName", out _); + } + + private static bool StartsWithGeneratedNamespacePrefix(string prefix) + { + if (!prefix.StartsWith("ns", StringComparison.Ordinal) || + prefix.Length == 2) + { + return false; + } + for (int ii = 2; ii < prefix.Length; ii++) + { + if (prefix[ii] is not (>= '0' and <= '9')) + { + return false; + } + } + return true; + } + + private static bool IsExternalRelationPrefix(string prefix) + { + return prefix is "http" or "https" or "urn"; + } + + private static string DefaultReferenceType(string rel) + { + if (string.Equals(rel, "uav:componentModel", StringComparison.Ordinal)) + { + return "HasComponent"; + } + return "Organizes"; + } + + private static bool IsNodeId(string reference) + { + return reference.StartsWith("ns=", StringComparison.Ordinal) || + reference.StartsWith("nsu=", StringComparison.Ordinal) || + reference.StartsWith("svr=", StringComparison.Ordinal) || + reference.StartsWith("i=", StringComparison.Ordinal) || + reference.StartsWith("s=", StringComparison.Ordinal) || + reference.StartsWith("g=", StringComparison.Ordinal) || + reference.StartsWith("b=", StringComparison.Ordinal); + } + + private static string GenerateNodeId(string browsePath) + { + return "ns=1;s=" + browsePath; + } + + /// + /// Renders a NodeSet-local NodeId string as a portable OPC 10000-6 + /// ExpandedNodeId (WoT Binding Section 5.1.1): namespace 0 keeps its + /// canonical i=/s= form, while a higher namespace index is + /// resolved to nsu=<NamespaceUri>;... through the source + /// NodeSet's NamespaceUris table so the value survives a + /// namespace-table reordering. The session-local ns=<index> + /// form is never emitted. An unparseable or unresolvable value is left + /// untouched. + /// + private static string? ToPortableNodeId(string? rawNodeId, string[]? namespaceUris) + { + if (string.IsNullOrEmpty(rawNodeId)) + { + return rawNodeId; + } + NodeId parsed; + try + { + parsed = NodeId.Parse(rawNodeId!); + } + catch (ServiceResultException) + { + return rawNodeId; + } + var buffer = new System.Text.StringBuilder(); + ushort index = parsed.NamespaceIndex; + if (index != 0) + { + if (namespaceUris is null || index - 1 >= namespaceUris.Length) + { + return rawNodeId; + } + buffer.Append("nsu=") + .Append(CoreUtils.EscapeUri(namespaceUris[index - 1])) + .Append(';'); + } + NodeId.Format( + System.Globalization.CultureInfo.InvariantCulture, + buffer, + parsed.IdentifierAsString, + parsed.IdType, + 0); + return buffer.ToString(); + } + + private static string? ToPortableQualifiedName( + string? rawBrowseName, + string[]? namespaceUris) + { + if (string.IsNullOrEmpty(rawBrowseName) || + rawBrowseName!.StartsWith("nsu=", StringComparison.Ordinal)) + { + return rawBrowseName; + } + int separator = -1; + for (int ii = 0; ii < rawBrowseName.Length; ii++) + { + if (rawBrowseName[ii] == ':') + { + separator = ii; + break; + } + if (rawBrowseName[ii] is not (>= '0' and <= '9')) + { + return rawBrowseName; + } + } + if (separator <= 0 || separator + 1 >= rawBrowseName.Length) + { + return rawBrowseName; + } + int namespaceIndex = 0; + for (int ii = 0; ii < separator; ii++) + { + int digit = rawBrowseName[ii] - '0'; + if (namespaceIndex > (int.MaxValue - digit) / 10) + { + return rawBrowseName; + } + namespaceIndex = (namespaceIndex * 10) + digit; + } + string name = rawBrowseName.Substring(separator + 1); + if (namespaceIndex == 0) + { + return name; + } + if (namespaceUris is null || namespaceIndex > namespaceUris.Length) + { + return rawBrowseName; + } + return "ns" + + namespaceIndex.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + ":" + + name; + } + + private static string ToNodeSetQualifiedName( + WotDocument document, + string rawBrowseName, + UANodeSet nodeSet, + List diagnostics) + { + if (rawBrowseName.StartsWith("nsu=", StringComparison.Ordinal)) + { + int delimiter = rawBrowseName.IndexOf(';', 4); + if (delimiter < 0 || delimiter + 1 >= rawBrowseName.Length) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NonPortableQualifiedName, + $"The uav:browseName '{rawBrowseName}' is not a valid " + + "NamespaceUri-qualified QualifiedName.")); + return rawBrowseName; + } + string namespaceUri = CoreUtils.UnescapeUri( + rawBrowseName.AsSpan(4, delimiter - 4)); + string name = rawBrowseName.Substring(delimiter + 1); + if (string.Equals( + namespaceUri, + WotVocabulary.OpcUaNamespace, + StringComparison.Ordinal)) + { + return name; + } + int namespaceIndex = GetOrAppendNamespaceUri(nodeSet, namespaceUri); + return namespaceIndex.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + ":" + + name; + } + + int separator = -1; + for (int ii = 0; ii < rawBrowseName.Length; ii++) + { + if (rawBrowseName[ii] == ':') + { + separator = ii; + break; + } + } + if (separator > 0) + { + bool numeric = true; + for (int ii = 0; ii < separator; ii++) + { + if (rawBrowseName[ii] is not (>= '0' and <= '9')) + { + numeric = false; + break; + } + } + if (numeric) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.NonPortableQualifiedName, + $"The uav:browseName '{rawBrowseName}' uses a numeric " + + "NamespaceIndex; persisted documents shall use a " + + "context prefix or nsu=;.", + new WotLocation(reference: rawBrowseName))); + return rawBrowseName; + } + string prefix = rawBrowseName.Substring(0, separator); + if (!TryGetContextNamespace(document, prefix, out string namespaceUri)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NonPortableQualifiedName, + $"The uav:browseName '{rawBrowseName}' uses an unbound " + + $"context prefix '{prefix}'.", + new WotLocation(reference: rawBrowseName))); + return rawBrowseName; + } + string name = rawBrowseName.Substring(separator + 1); + if (string.Equals( + namespaceUri, + WotVocabulary.OpcUaNamespace, + StringComparison.Ordinal)) + { + return name; + } + int namespaceIndex = GetOrAppendNamespaceUri(nodeSet, namespaceUri); + return namespaceIndex.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + ":" + + name; + } + return rawBrowseName; + } + + private static string ToNodeSetNodeId( + string portableNodeId, + UANodeSet nodeSet, + List diagnostics) + { + if (portableNodeId.StartsWith("nsu=", StringComparison.Ordinal)) + { + int delimiter = portableNodeId.IndexOf(';', 4); + if (delimiter < 0 || delimiter + 1 >= portableNodeId.Length) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ValidationError, + $"The NodeId '{portableNodeId}' is not a valid " + + "NamespaceUri-qualified NodeId.")); + return portableNodeId; + } + string namespaceUri = CoreUtils.UnescapeUri( + portableNodeId.AsSpan(4, delimiter - 4)); + string identifier = portableNodeId.Substring(delimiter + 1); + if (string.Equals( + namespaceUri, + WotVocabulary.OpcUaNamespace, + StringComparison.Ordinal)) + { + return identifier; + } + int namespaceIndex = GetOrAppendNamespaceUri(nodeSet, namespaceUri); + return "ns=" + + namespaceIndex.ToString( + System.Globalization.CultureInfo.InvariantCulture) + + ";" + + identifier; + } + return portableNodeId; + } + + private static int GetOrAppendNamespaceUri( + UANodeSet nodeSet, + string namespaceUri) + { + if (nodeSet.NamespaceUris is not null) + { + for (int ii = 0; ii < nodeSet.NamespaceUris.Length; ii++) + { + if (string.Equals( + nodeSet.NamespaceUris[ii], + namespaceUri, + StringComparison.Ordinal)) + { + return ii + 1; + } + } + } + var uris = nodeSet.NamespaceUris is null + ? new List() + : new List(nodeSet.NamespaceUris); + uris.Add(namespaceUri); + nodeSet.NamespaceUris = [.. uris]; + return uris.Count; + } + + private static bool HasEventTypeAnnotation(WotDocument document) + { + foreach (string token in document.TypeTokens) + { + if (string.Equals(token, WotVocabulary.EventTypeAnnotation, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + /// + /// Determines whether the projected root ObjectType derives from + /// BaseEventType and therefore projects a UA EventType, annotated with + /// uav:eventType (WoT Binding Section 5.2). + /// + private static bool IsEventTypeRoot(UANode? root, UANodeSet nodeSet) + { + if (root is not UAObjectType) + { + return false; + } + Dictionary index = BuildIndex(nodeSet); + UANode? current = root; + int guard = index.Count + 1; + while (current is UAObjectType && guard-- > 0) + { + string? superType = FindSuperTypeId(current); + if (superType is null) + { + return false; + } + if (string.Equals(superType, WotVocabulary.BaseEventType, StringComparison.Ordinal)) + { + return true; + } + if (!index.TryGetValue(superType, out current)) + { + return false; + } + } + return false; + } + + private static string? FindSuperTypeId(UANode node) + { + if (node.References is null) + { + return null; + } + foreach (Reference reference in node.References) + { + if (!reference.IsForward && reference.Value is not null && + (string.Equals(reference.ReferenceType, "HasSubtype", StringComparison.Ordinal) || + string.Equals(reference.ReferenceType, WotVocabulary.HasSubtype, StringComparison.Ordinal))) + { + return reference.Value; + } + } + return null; + } + + /// + /// Portable identity validation (WoT Binding Section 5.1.1): every + /// NodeId-valued term (uav:id, each uav:hasComponent / + /// uav:componentOf entry, uav:mapToNodeId, + /// uav:mapToType, uav:refId, and a + /// ?id= href) shall be a portable ExpandedNodeId, never the + /// session-local ns=<index> form. The exact uav:nodeSet + /// envelope and uav:nodes projection subtrees are skipped so their + /// own namespace indices - resolved through their own NamespaceUris table - + /// are unaffected. + /// + private static void ValidatePortableIdentity( + WotDocument document, + List diagnostics) + { + ValidatePortableIdentity(document.RootElement, diagnostics); + } + + private static void ValidatePortableIdentity( + JsonElement element, + List diagnostics) + { + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty member in element.EnumerateObject()) + { + if (string.Equals(member.Name, "uav:nodeSet", StringComparison.Ordinal) || + string.Equals(member.Name, "uav:nodes", StringComparison.Ordinal)) + { + // Exact preservation subtrees keep their own indices. + continue; + } + CheckPortableMember(member.Name, member.Value, diagnostics); + ValidatePortableIdentity(member.Value, diagnostics); + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + ValidatePortableIdentity(item, diagnostics); + } + break; + } + } + + private static void CheckPortableMember( + string name, + JsonElement value, + List diagnostics) + { + switch (name) + { + case "uav:id": + case "uav:mapToNodeId": + case "uav:mapToType": + if (value.ValueKind == JsonValueKind.String) + { + CheckPortableValue(name, value.GetString(), diagnostics); + } + break; + case "uav:hasComponent": + case "uav:componentOf": + if (value.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement entry in value.EnumerateArray()) + { + if (entry.ValueKind == JsonValueKind.String) + { + CheckPortableValue(name + " entry", entry.GetString(), diagnostics); + } + } + } + break; + case "uav:refId": + if (value.ValueKind == JsonValueKind.String) + { + CheckPortableValue(name, value.GetString(), diagnostics); + } + break; + case "href": + if (value.ValueKind == JsonValueKind.String && + value.GetString() is { } href) + { + int marker = href.IndexOf("?id=", StringComparison.Ordinal); + if (marker >= 0) + { + CheckPortableValue( + "href ?id=", href.Substring(marker + 4), diagnostics); + } + } + break; + } + } + + private static void CheckPortableValue( + string term, + string? value, + List diagnostics) + { + if (string.IsNullOrEmpty(value)) + { + return; + } + if (!IsNodeId(value!)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ValidationError, + $"The NodeId-valued term {term} uses '{value}', which is not " + + "an ExpandedNodeId.", + new WotLocation(reference: value))); + return; + } + int marker = value!.IndexOf("ns=", StringComparison.Ordinal); + if (marker >= 0 && + marker + 3 < value.Length && + char.IsDigit(value[marker + 3]) && + (marker == 0 || value[marker - 1] is ';' or '=')) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.NonPortableIdentity, + $"The portable identity term {term} uses the session-local " + + $"ns= form ('{value}'); a persisted document shall use an " + + "ExpandedNodeId (nsu=;... or namespace-0 i=...) " + + "so it survives a namespace-table reordering (WoT Binding Section 5.1.1).", + new WotLocation(reference: value))); + } + } + + private static void ValidateModelConceptNames( + WotDocument document, + List diagnostics) + { + ValidateModelConceptNames( + document, + document.RootElement, + diagnostics); + } + + private static void ValidateModelConceptNames( + WotDocument document, + JsonElement element, + List diagnostics) + { + if (element.ValueKind == JsonValueKind.Object) + { + ValidateReferenceTypeRelation(document, element, diagnostics); + ValidateModelConceptMember( + document, + element, + "uav:mapToTypeName", + requiredDefinitiveMember: "uav:mapToType", + diagnostics); + ValidateModelConceptMember( + document, + element, + "uav:congruentTypeName", + requiredDefinitiveMember: "uav:congruentType", + diagnostics); + foreach (JsonProperty member in element.EnumerateObject()) + { + if (member.Name is not ("uav:nodeSet" or "uav:nodes")) + { + ValidateModelConceptNames( + document, + member.Value, + diagnostics); + } + } + } + else if (element.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement item in element.EnumerateArray()) + { + ValidateModelConceptNames(document, item, diagnostics); + } + } + } + + private static void ValidateModelConceptMember( + WotDocument document, + JsonElement element, + string memberName, + string? requiredDefinitiveMember, + List diagnostics) + { + if (!element.TryGetProperty(memberName, out JsonElement member)) + { + return; + } + string? value = member.ValueKind == JsonValueKind.String + ? member.GetString() + : null; + if (value is null || + !TrySplitCompactModelName( + value, + out string prefix, + out _) || + !TryGetContextNamespace(document, prefix, out _)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptUnresolved, + $"The {memberName} value '{value}' is not a compact model name " + + "whose non-numeric prefix is bound in @context.", + new WotLocation(reference: value))); + } + if (requiredDefinitiveMember is not null && + !element.TryGetProperty(requiredDefinitiveMember, out _)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptUnresolved, + $"{memberName} requires {requiredDefinitiveMember}.", + new WotLocation(reference: value))); + } + } + + private static void ValidateReferenceTypeRelation( + WotDocument document, + JsonElement element, + List diagnostics) + { + string? rel = GetElementString(element, "rel"); + if (rel is null) + { + return; + } + if (IsKnownBindingRelation(rel) || + rel.StartsWith("http:", StringComparison.Ordinal) || + rel.StartsWith("https:", StringComparison.Ordinal) || + rel.StartsWith("urn:", StringComparison.Ordinal)) + { + return; + } + if (!TrySplitCompactModelName( + rel, + out string prefix, + out _) || + prefix == "tm") + { + return; + } + if (prefix == "uav") + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptUnresolved, + $"The Binding relation '{rel}' is not defined.", + new WotLocation(reference: rel))); + return; + } + if (IsExternalRelationPrefix(prefix) || + !IsModelConceptCandidate(element, prefix)) + { + return; + } + if (!TryGetContextNamespace(document, prefix, out _)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ModelConceptUnresolved, + $"The ReferenceType relation '{rel}' uses a prefix that is " + + "not bound in @context.", + new WotLocation(reference: rel))); + } + } + + /// + /// Event-annotation consistency (WoT Binding Section 5.2): an event + /// affordance annotated @type: uav:eventType shall not set + /// uav:isEvent: false; the two forms record the same fact. + /// + private static void ValidateEventAnnotations( + WotDocument document, + List diagnostics) + { + foreach (KeyValuePair affordance in document.Events) + { + JsonElement node = affordance.Value; + if (node.ValueKind != JsonValueKind.Object || !HasEventTypeType(node)) + { + continue; + } + if (node.TryGetProperty("uav:isEvent", out JsonElement isEvent) && + isEvent.ValueKind == JsonValueKind.False) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.EventAnnotationConflict, + $"The event affordance '{affordance.Key}' is annotated " + + "@type uav:eventType but sets uav:isEvent: false; the two " + + "forms record the same EventType projection (WoT Binding Section 5.2).", + WotLocation.FromPointer("/events/" + affordance.Key))); + } + } + } + + private static bool HasEventTypeType(JsonElement node) + { + if (!node.TryGetProperty("@type", out JsonElement type)) + { + return false; + } + if (type.ValueKind == JsonValueKind.String) + { + return string.Equals( + type.GetString(), WotVocabulary.EventTypeAnnotation, StringComparison.Ordinal); + } + if (type.ValueKind == JsonValueKind.Array) + { + foreach (JsonElement token in type.EnumerateArray()) + { + if (token.ValueKind == JsonValueKind.String && + string.Equals( + token.GetString(), WotVocabulary.EventTypeAnnotation, StringComparison.Ordinal)) + { + return true; + } + } + } + return false; + } + + private static string DeriveModelUri(WotDocument document) + { + string? uavId = GetUavString(document, "id"); + if (uavId is not null) + { + const string marker = "nsu="; + if (uavId.StartsWith(marker, StringComparison.Ordinal)) + { + int semicolon = uavId.IndexOf(';', marker.Length); + string ns = semicolon < 0 + ? uavId.Substring(marker.Length) + : uavId.Substring(marker.Length, semicolon - marker.Length); + if (ns.Length > 0) + { + return ns; + } + } + } + string? id = document.Id; + if (!string.IsNullOrEmpty(id)) + { + return id!; + } + return "urn:opcua:wot:synthesized"; + } + + private static string? MapDataTypeToJson(string? dataType) + { + if (dataType is not null && + s_dataTypeToJsonType.TryGetValue(dataType, out string? jsonType)) + { + return jsonType; + } + return null; + } + + private static string MapJsonSchemaToDataType(JsonElement schema) + { + return WotVocabulary.MapJsonTypeToDataType(GetElementString(schema, "type")); + } + + private static uint MapAccessLevel(JsonElement schema) + { + bool readOnly = GetElementBool(schema, "readOnly"); + bool writeOnly = GetElementBool(schema, "writeOnly"); + uint access = 0; + if (!writeOnly) + { + access |= AccessLevelCurrentRead; + } + if (!readOnly) + { + access |= AccessLevelCurrentWrite; + } + return access == 0 ? AccessLevelCurrentRead : access; + } + + private static string UniqueKey(string? candidate, HashSet used) + { + string key = string.IsNullOrEmpty(candidate) ? "member" : candidate!; + if (used.Add(key)) + { + return key; + } + int suffix = 2; + string unique = key + "_" + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); + while (!used.Add(unique)) + { + suffix++; + unique = key + "_" + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture); + } + return unique; + } + + private static string? LocalName(string? browseName) + { + if (string.IsNullOrEmpty(browseName)) + { + return null; + } + if (browseName!.StartsWith("nsu=", StringComparison.Ordinal)) + { + int delimiter = -1; + for (int ii = 4; ii < browseName.Length; ii++) + { + if (browseName[ii] == ';') + { + delimiter = ii; + break; + } + } + return delimiter >= 0 && delimiter + 1 < browseName.Length + ? browseName.Substring(delimiter + 1) + : null; + } + int colon = browseName!.IndexOf(':', StringComparison.Ordinal); + return colon >= 0 && colon + 1 < browseName.Length + ? browseName.Substring(colon + 1) + : browseName; + } + + private static string? SanitizeName(string? title) + { + if (string.IsNullOrEmpty(title)) + { + return null; + } + var builder = new System.Text.StringBuilder(title!.Length); + foreach (char character in title!) + { + if (char.IsLetterOrDigit(character) || character is '_' or '-') + { + builder.Append(character); + } + } + return builder.Length == 0 ? null : builder.ToString(); + } + + private static Opc.Ua.Export.LocalizedText[] MakeText(string value) + { + return [new Opc.Ua.Export.LocalizedText { Value = value }]; + } + + private static string? FirstText(Opc.Ua.Export.LocalizedText[]? texts) + { + if (texts is null) + { + return null; + } + foreach (Opc.Ua.Export.LocalizedText text in texts) + { + if (!string.IsNullOrEmpty(text.Value)) + { + return text.Value; + } + } + return null; + } + + private static void WriteOptional(Utf8JsonWriter writer, string name, string? value) + { + if (!string.IsNullOrEmpty(value)) + { + writer.WriteString(name, value); + } + } + + private static string? GetUavString(WotDocument document, string localName) + { + return document.TryGetUav(localName, out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static string? GetRootString(WotDocument document, string name) + { + JsonElement root = document.RootElement; + return root.ValueKind == JsonValueKind.Object && + root.TryGetProperty(name, out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static string? GetElementString(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(name, out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static bool GetElementBool(JsonElement element, string name) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(name, out JsonElement value) && + value.ValueKind == JsonValueKind.True; + } + } +} diff --git a/src/Opc.Ua.Types/Wot/WotNodeSetConverter.cs b/src/Opc.Ua.Types/Wot/WotNodeSetConverter.cs new file mode 100644 index 0000000000..f3f382de6c --- /dev/null +++ b/src/Opc.Ua.Types/Wot/WotNodeSetConverter.cs @@ -0,0 +1,778 @@ +/* ======================================================================== + * 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.Diagnostics.CodeAnalysis; +using System.IO; +using System.Security.Cryptography; +using System.Text.Json; +using Opc.Ua.Export; + +namespace Opc.Ua.Wot +{ + /// + /// Converts OPC UA NodeSet2 documents to and from WoT Thing Models and + /// Thing Descriptions. The default output uses the semantic/readable + /// mapping of the OPC UA WoT Binding and adds the schema-complete, + /// deterministic uav:nodes projection only when needed; the byte-exact + /// uav:nodeSet envelope is an explicit or last-resort fallback. + /// + public static partial class WotNodeSetConverter + { + /// + /// OPC UA WoT Binding vocabulary namespace. + /// + public const string VocabularyNamespace = WotVocabulary.VocabularyNamespace; + + /// + /// Creates a deterministic WoT Thing Model/Thing Description with + /// readable affordances and an exceptional complete + /// uav:nodes projection when required. The preservation envelope is governed by + /// . + /// + /// The NodeSet2 document to convert. + /// An optional document title. + /// Resource limits; defaults are used when omitted. + /// The generated, byte-preserving WoT document. + public static WotDocument FromNodeSet( + UANodeSet nodeSet, + string? title = null, + WotNodeSetConverterOptions? options = null) + { + WotConversionResult result = FromNodeSetResult(nodeSet, title, options); + ThrowIfErrors(result.Diagnostics); + return result.Value + ?? throw new FormatException("The NodeSet could not be converted to a WoT document."); + } + + /// + /// Creates a WoT document from a NodeSet2 document, returning structured + /// diagnostics together with the result. + /// + /// The NodeSet2 document to convert. + /// An optional document title. + /// Resource limits; defaults are used when omitted. + /// The conversion result and its diagnostics. + [SuppressMessage( + "Reliability", + "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the returned WotDocument is transferred to the caller through the result.")] + public static WotConversionResult FromNodeSetResult( + UANodeSet nodeSet, + string? title = null, + WotNodeSetConverterOptions? options = null) + { + if (nodeSet is null) + { + throw new ArgumentNullException(nameof(nodeSet)); + } + options ??= new WotNodeSetConverterOptions(); + options.Validate(); + + var diagnostics = new List(); + + byte[] nodeSetBytes; + using (var nodeSetStream = new MemoryStream()) + { + nodeSet.Write(nodeSetStream); + if (nodeSetStream.Length > options.MaxNodeSetSize) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NodeSetTooLarge, + $"NodeSet exceeds the configured {options.MaxNodeSetSize} byte limit.")); + return new WotConversionResult(null, diagnostics); + } + nodeSetBytes = nodeSetStream.ToArray(); + } + + UANode? root = SelectRootNode(nodeSet); + string resolvedTitle = title + ?? FirstText(root?.DisplayName) + ?? (nodeSet.Models is { Length: > 0 } && + !string.IsNullOrEmpty(nodeSet.Models[0].ModelUri) + ? nodeSet.Models[0].ModelUri! + : "OPC UA NodeSet"); + + byte[] digest = ComputeSha256(nodeSetBytes); + var nativeDiagnostics = new List(); + byte[] nativeProjection = WotNativeProjection.Write( + nodeSet, + options, + nativeDiagnostics); + bool nativeComplete = false; + string? nativeDifference = null; + if (!HasErrors(nativeDiagnostics)) + { + using JsonDocument nativeDocument = JsonDocument.Parse(nativeProjection); + var reconstructionDiagnostics = new List(); + UANodeSet? reconstructed = WotNativeProjection.Read( + nativeDocument.RootElement, + options, + reconstructionDiagnostics); + if (reconstructed is not null && !HasErrors(reconstructionDiagnostics)) + { + NodeSetComparisonResult comparison = + NodeSetComparer.Compare(nodeSet, reconstructed); + nativeComplete = comparison.AreEquivalent; + if (!nativeComplete && comparison.Differences.Count > 0) + { + nativeDifference = comparison.Differences[0]; + } + } + else + { + nativeDifference = FirstDiagnosticMessage(reconstructionDiagnostics); + } + } + else + { + nativeDifference = FirstDiagnosticMessage(nativeDiagnostics); + } + + bool emitEnvelope = options.PreservationMode == + WotNodeSetPreservationMode.Always; + if (!nativeComplete) + { + string reason = nativeDifference ?? + "The structured native projection did not reproduce the source NodeSet."; + if (options.PreservationMode == WotNodeSetPreservationMode.Never) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NativeProjectionIncomplete, + reason)); + return new WotConversionResult(null, diagnostics); + } + emitEnvelope = true; + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Warning, + WotDiagnosticCode.NativeProjectionIncomplete, + reason + " The uav:nodeSet fallback was emitted.")); + } + + byte[] json; + using (var output = new MemoryStream()) + { + using (var writer = new Utf8JsonWriter( + output, + new JsonWriterOptions { Indented = true, SkipValidation = false })) + { + writer.WriteStartObject(); + WriteContext(writer, nodeSet); + bool rootIsEventType = IsEventTypeRoot(root, nodeSet); + WriteRootType(writer, root, rootIsEventType); + writer.WriteString("title", resolvedTitle); + if (!string.IsNullOrEmpty(root?.BrowseName)) + { + writer.WriteString( + "uav:browseName", + ToPortableQualifiedName( + root!.BrowseName, + nodeSet.NamespaceUris)); + } + if (!string.IsNullOrEmpty(root?.NodeId)) + { + string? portableId = ToPortableNodeId(root!.NodeId, nodeSet.NamespaceUris); + if (!string.IsNullOrEmpty(portableId)) + { + writer.WriteString("uav:id", portableId); + } + } + if (rootIsEventType) + { + writer.WriteBoolean("uav:isEvent", true); + } + WriteDescription(writer, root?.Description); + WriteAffordances(writer, nodeSet, root, diagnostics, options); + + if (emitEnvelope) + { + writer.WritePropertyName("uav:nodeSet"); + writer.WriteStartObject(); + writer.WriteString("@type", WotVocabulary.EnvelopeType); + writer.WriteString("contentType", WotVocabulary.NodeSetContentType); + writer.WriteString("encoding", WotVocabulary.Base64Encoding); + writer.WriteString("sha256", ToLowerHex(digest)); + writer.WriteString("data", System.Convert.ToBase64String(nodeSetBytes)); + writer.WriteString("profileVersion", WotVocabulary.ProfileVersion); + writer.WriteEndObject(); + } + + writer.WritePropertyName("uav:nodes"); + using (JsonDocument nativeDocument = JsonDocument.Parse(nativeProjection)) + { + nativeDocument.RootElement.WriteTo(writer); + } + + writer.WriteEndObject(); + } + json = output.ToArray(); + } + + json = WotJsonResidue.Apply(json, nodeSet, options, diagnostics); + if (IsReadableMappingComplete(json, nodeSet, options)) + { + json = RemoveRootMembers(json, options, "uav:nodes"); + } + if (json.Length > options.MaxJsonDocumentSize) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.JsonDocumentTooLarge, + $"Generated WoT document exceeds the configured " + + $"{options.MaxJsonDocumentSize} byte limit.")); + return new WotConversionResult(null, diagnostics); + } + WotDocument document = WotDocument.FromOwnedBytes(json, options); + return new WotConversionResult(document, diagnostics); + } + + private static bool IsReadableMappingComplete( + byte[] json, + UANodeSet source, + WotNodeSetConverterOptions options) + { + byte[] readable = RemoveRootMembers( + json, + options, + "uav:nodes", + "uav:nodeSet"); + using WotDocument document = WotDocument.Parse(readable, options); + WotConversionResult result = + ToNodeSetResult(document, options); + return result.Success && + NodeSetComparer.Compare(source, result.Value!).AreEquivalent; + } + + private static byte[] RemoveRootMembers( + byte[] json, + WotNodeSetConverterOptions options, + params string[] names) + { + using WotDocument document = WotDocument.Parse(json, options); + var excluded = new HashSet(names, StringComparer.Ordinal); + using var output = new MemoryStream(); + using (var writer = new Utf8JsonWriter( + output, + new JsonWriterOptions { Indented = true, SkipValidation = false })) + { + writer.WriteStartObject(); + foreach (JsonProperty member in document.RootElement.EnumerateObject()) + { + if (!excluded.Contains(member.Name)) + { + writer.WritePropertyName(member.Name); + member.Value.WriteTo(writer); + } + } + writer.WriteEndObject(); + } + return output.ToArray(); + } + + /// + /// Restores or synthesizes the NodeSet2 document described by a WoT + /// document, throwing on any error diagnostic. + /// + /// The WoT document. + /// Resource limits; defaults are used when omitted. + /// The restored or synthesized NodeSet2 document. + /// Thrown when the conversion fails. + public static UANodeSet ToNodeSet( + WotDocument document, + WotNodeSetConverterOptions? options = null) + { + if (document is null) + { + throw new ArgumentNullException(nameof(document)); + } + + var diagnostics = new List(); + UANodeSet? nodeSet = ToNodeSetCore(document, options, null, null, diagnostics); + ThrowIfErrors(diagnostics); + return nodeSet + ?? throw new FormatException("The WoT document could not be converted to a NodeSet."); + } + + /// + /// Parses and restores or synthesizes a NodeSet2 document from UTF-8 WoT + /// JSON, throwing on any error diagnostic. + /// + /// The UTF-8 encoded WoT document. + /// Resource limits; defaults are used when omitted. + /// The restored or synthesized NodeSet2 document. + public static UANodeSet ToNodeSet( + ReadOnlyMemory utf8Json, + WotNodeSetConverterOptions? options = null) + { + using WotDocument document = WotDocument.Parse(utf8Json, options); + return ToNodeSet(document, options); + } + + /// + /// Selects the root node of a projected NodeSet2 - the ObjectType or + /// VariableType a Thing Model materializes, or the top-level Object a + /// Thing Description projects - and returns it as an absolute + /// whose + /// is resolved from the NodeSet's own namespace table. Returns + /// null when the NodeSet carries no nodes or the root NodeId + /// cannot be parsed. + /// + /// The projected NodeSet2 document. + /// + /// The root node as an absolute ExpandedNodeId, or null when the + /// NodeSet has no identifiable root. + /// + /// + /// Thrown when is null. + /// + public static ExpandedNodeId? TrySelectProjectionRoot(UANodeSet nodeSet) + { + if (nodeSet is null) + { + throw new ArgumentNullException(nameof(nodeSet)); + } + UANode? root = SelectRootNode(nodeSet); + if (root?.NodeId is not { Length: > 0 } rawNodeId) + { + return null; + } + NodeId parsed; + try + { + parsed = NodeId.Parse(rawNodeId); + } + catch (ServiceResultException) + { + return null; + } + ushort localIndex = parsed.NamespaceIndex; + string namespaceUri; + if (localIndex == 0) + { + namespaceUri = Opc.Ua.Types.Namespaces.OpcUa; + } + else if (nodeSet.NamespaceUris is { Length: > 0 } uris && + localIndex - 1 < uris.Length) + { + namespaceUri = uris[localIndex - 1]; + } + else + { + return null; + } + return new ExpandedNodeId(parsed, namespaceUri); + } + + /// + /// Restores or synthesizes the NodeSet2 document described by a WoT + /// document, returning structured diagnostics together with the result. + /// + /// The WoT document. + /// Resource limits; defaults are used when omitted. + /// An optional resolver for referenced TD/TM documents. + /// An optional resolution context for cycle and limit tracking. + /// The conversion result and its diagnostics. + public static WotConversionResult ToNodeSetResult( + WotDocument document, + WotNodeSetConverterOptions? options = null, + IWotThingResolver? thingResolver = null, + WotResolutionContext? resolutionContext = null) + { + if (document is null) + { + throw new ArgumentNullException(nameof(document)); + } + var diagnostics = new List(); + UANodeSet? nodeSet = ToNodeSetCore( + document, options, thingResolver, resolutionContext, diagnostics); + return new WotConversionResult(nodeSet, diagnostics); + } + + private static UANodeSet? ToNodeSetCore( + WotDocument document, + WotNodeSetConverterOptions? options, + IWotThingResolver? thingResolver, + WotResolutionContext? resolutionContext, + List diagnostics) + { + options ??= new WotNodeSetConverterOptions(); + options.Validate(); + + // Exactly one resolution context is created per top-level + // conversion, seeded from the converter options, and threaded + // through every context/schema/thing/link resolution below. It + // must never be re-created per link so that depth, document + // count, cycle and cumulative byte bounds apply across the whole + // conversion rather than resetting for each resolved reference. + resolutionContext ??= new WotResolutionContext(options.ToResolverOptions()); + + if (document.TryGetEnvelope(out JsonElement envelope)) + { + UANodeSet? restored = RestoreFromEnvelope(envelope, options, diagnostics); + if (restored is null) + { + return null; + } + if (document.TryGetNativeProjection(out JsonElement projection)) + { + ValidateNativeConsistency(restored, projection, options, diagnostics); + } + WotJsonResidue.Replace(restored, document, options, diagnostics); + return restored; + } + + if (document.TryGetNativeProjection(out JsonElement nativeProjection)) + { + UANodeSet? restored = WotNativeProjection.Read( + nativeProjection, + options, + diagnostics); + if (restored is not null) + { + WotJsonResidue.Replace(restored, document, options, diagnostics); + } + return restored; + } + + UANodeSet? synthesized = + Synthesize(document, options, thingResolver, resolutionContext, diagnostics); + if (synthesized is not null) + { + WotJsonResidue.Replace(synthesized, document, options, diagnostics); + } + return synthesized; + } + + private static UANodeSet? RestoreFromEnvelope( + JsonElement envelope, + WotNodeSetConverterOptions options, + List diagnostics) + { + var location = new WotLocation(jsonPointer: "/uav:nodeSet"); + + if (!TryGetString(envelope, "contentType", out string? contentType) || + !string.Equals(contentType, WotVocabulary.NodeSetContentType, StringComparison.Ordinal)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.UnsupportedContentType, + $"Unsupported NodeSet content type '{contentType}'.", + location)); + return null; + } + + if (!TryGetString(envelope, "encoding", out string? encoding) || + !string.Equals(encoding, WotVocabulary.Base64Encoding, StringComparison.Ordinal)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.UnsupportedEncoding, + $"Unsupported NodeSet encoding '{encoding}'.", + location)); + return null; + } + + if (!TryGetString(envelope, "data", out string? data) || data is null) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.EnvelopeInvalid, + "The uav:nodeSet data value is required and must be a string.", + location)); + return null; + } + + byte[] nodeSetBytes; + try + { + nodeSetBytes = System.Convert.FromBase64String(data); + } + catch (FormatException) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.InvalidBase64, + "The uav:nodeSet data is not valid base64.", + location)); + return null; + } + + if (nodeSetBytes.Length > options.MaxNodeSetSize) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NodeSetTooLarge, + $"Decoded NodeSet exceeds the configured {options.MaxNodeSetSize} byte limit.", + location)); + return null; + } + + // uav:nodeSet.sha256 is mandatory: a preservation envelope without + // an integrity digest cannot be trusted and must not yield a + // NodeSet, regardless of whether the payload otherwise parses. + if (!envelope.TryGetProperty("sha256", out JsonElement digestElement) || + digestElement.ValueKind != JsonValueKind.String) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.InvalidDigest, + "The uav:nodeSet sha256 value is required and must be a string.", + location)); + return null; + } + + if (!TryParseDigest(digestElement.GetString()!, out byte[] expected)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.InvalidDigest, + "The uav:nodeSet sha256 value is not a valid SHA-256 digest.", + location)); + return null; + } + + byte[] actual = ComputeSha256(nodeSetBytes); + if (!FixedEquals(expected, actual)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.DigestMismatch, + "The uav:nodeSet digest does not match the payload.", + location)); + return null; + } + + UANodeSet? nodeSet; + try + { + using (var stream = new MemoryStream(nodeSetBytes, writable: false)) + { + nodeSet = UANodeSet.Read(stream); + } + } + catch (Exception ex) when ( + ex is InvalidOperationException or + System.Xml.XmlException or + FormatException) + { + // XmlSerializer wraps parse failures in InvalidOperationException; + // treat any deserialization failure as a structured diagnostic + // rather than letting the exception escape the converter. + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.MalformedNodeSet, + $"The uav:nodeSet payload is not a valid NodeSet2 document: {ex.Message}", + location)); + return null; + } + if (nodeSet is null) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.MalformedNodeSet, + "The uav:nodeSet payload is not a valid NodeSet2 document.", + location)); + } + return nodeSet; + } + + private static void ValidateNativeConsistency( + UANodeSet baseline, + JsonElement projection, + WotNodeSetConverterOptions options, + List diagnostics) + { + var nativeDiagnostics = new List(); + UANodeSet? projected = WotNativeProjection.Read( + projection, + options, + nativeDiagnostics); + if (projected is null || HasErrors(nativeDiagnostics)) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NativeProjectionConflict, + FirstDiagnosticMessage(nativeDiagnostics) ?? + "The native projection could not be reconstructed.")); + return; + } + + NodeSetComparisonResult comparison = NodeSetComparer.Compare(baseline, projected); + if (!comparison.AreEquivalent) + { + diagnostics.Add(new WotDiagnostic( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.NativeProjectionConflict, + comparison.Differences.Count > 0 + ? comparison.Differences[0] + : "The native projection conflicts with the preservation baseline.")); + } + } + + private static bool HasErrors(List diagnostics) + { + for (int ii = 0; ii < diagnostics.Count; ii++) + { + if (diagnostics[ii].Severity == WotDiagnosticSeverity.Error) + { + return true; + } + } + return false; + } + + private static string? FirstDiagnosticMessage( + List diagnostics) + { + return diagnostics.Count > 0 ? diagnostics[0].Message : null; + } + + private static string? GetBaselineModellingRule(UANode node) + { + if (node.References is null) + { + return null; + } + foreach (Reference reference in node.References) + { + if (string.Equals(reference.ReferenceType, "HasModellingRule", StringComparison.Ordinal) && + reference.IsForward && + reference.Value is not null && + WotVocabulary.TryGetModellingRuleName(reference.Value, out string rule)) + { + return rule; + } + } + return null; + } + + private static void ThrowIfErrors(IReadOnlyList diagnostics) + { + for (int ii = 0; ii < diagnostics.Count; ii++) + { + if (diagnostics[ii].Severity == WotDiagnosticSeverity.Error) + { + throw new FormatException(diagnostics[ii].ToString()); + } + } + } + + private static bool TryGetString(JsonElement element, string name, out string? value) + { + if (element.TryGetProperty(name, out JsonElement property) && + property.ValueKind == JsonValueKind.String) + { + value = property.GetString(); + return true; + } + value = null; + return false; + } + + 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) + { + const string digits = "0123456789abcdef"; + var chars = new char[data.Length * 2]; + for (int ii = 0; ii < data.Length; ii++) + { + chars[ii * 2] = digits[data[ii] >> 4]; + chars[(ii * 2) + 1] = digits[data[ii] & 0x0F]; + } + return new string(chars); + } + + private static bool TryParseDigest(string text, out byte[] digest) + { + string trimmed = text.Trim(); + if (trimmed.Length == 64 && IsHex(trimmed)) + { + digest = CoreUtils.FromHexString(trimmed); + return true; + } + try + { + byte[] decoded = System.Convert.FromBase64String(trimmed); + if (decoded.Length == 32) + { + digest = decoded; + return true; + } + } + catch (FormatException) + { + // Not base64; fall through. + } + digest = []; + return false; + } + + private static bool IsHex(string text) + { + foreach (char character in text) + { + bool isHex = (character >= '0' && character <= '9') || + (character >= 'a' && character <= 'f') || + (character >= 'A' && character <= 'F'); + if (!isHex) + { + return false; + } + } + return true; + } + + private static bool FixedEquals(byte[] left, byte[] right) + { + if (left.Length != right.Length) + { + return false; + } + int difference = 0; + for (int ii = 0; ii < left.Length; ii++) + { + difference |= left[ii] ^ right[ii]; + } + return difference == 0; + } + } +} diff --git a/src/Opc.Ua.Types/Wot/WotNodeSetConverterOptions.cs b/src/Opc.Ua.Types/Wot/WotNodeSetConverterOptions.cs new file mode 100644 index 0000000000..d0a114f1a3 --- /dev/null +++ b/src/Opc.Ua.Types/Wot/WotNodeSetConverterOptions.cs @@ -0,0 +1,198 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.Wot +{ + /// + /// Controls whether a converter emits the opaque byte-exact + /// uav:nodeSet preservation envelope. + /// + public enum WotNodeSetPreservationMode + { + /// + /// Emit the envelope only if the structured native projection cannot + /// reproduce the source NodeSet. + /// + WhenRequired, + + /// Always emit the byte-exact preservation envelope. + Always, + + /// + /// Never emit the envelope; report an error if native projection is not + /// complete. This mode is intended for conformance and completeness tests. + /// + Never + } + + /// + /// Resource limits and behavioural switches used while reading and + /// writing WoT documents, preservation envelopes and NodeSet2 payloads. + /// + /// + /// All limits are enforced deliberately so that a malformed or hostile + /// document cannot exhaust memory or stack. The defaults are generous + /// enough for real companion specifications yet bounded. + /// + public sealed class WotNodeSetConverterOptions + { + /// + /// Gets or sets the preservation-envelope policy. The default uses + /// readable mapping plus structured fallback and emits an opaque envelope + /// only when required. + /// + public WotNodeSetPreservationMode PreservationMode { get; set; } = + WotNodeSetPreservationMode.WhenRequired; + + /// + /// Gets or sets the maximum accepted WoT JSON document size in bytes. + /// + public int MaxJsonDocumentSize { get; set; } = 16 * 1024 * 1024; + + /// + /// Gets or sets the maximum accepted or decoded NodeSet2 XML size in bytes. + /// + public int MaxNodeSetSize { get; set; } = 64 * 1024 * 1024; + + /// + /// Gets or sets the maximum JSON nesting depth. + /// + public int MaxJsonDepth { get; set; } = 128; + + /// + /// Gets or sets the maximum XML nesting depth accepted when reading a + /// decoded or synthesized NodeSet2 document. + /// + public int MaxXmlDepth { get; set; } = 256; + + /// + /// Gets or sets the maximum number of UANode records projected into or + /// reconstructed from a native uav:nodes projection. + /// + public int MaxNodeCount { get; set; } = 1_000_000; + + /// + /// Gets or sets the maximum number of affordances (properties, actions + /// and events combined) processed for a single Thing. + /// + public int MaxAffordanceCount { get; set; } = 100_000; + + /// + /// Gets or sets the maximum external-document resolution depth used + /// when following contexts, schemas and referenced TD/TM documents. + /// + public int MaxResolverDepth { get; set; } = 16; + + /// + /// Gets or sets the maximum number of external documents (contexts, + /// schemas and referenced TD/TM documents combined) resolved for a + /// single top-level conversion. + /// + public int MaxResolverDocuments { get; set; } = 256; + + /// + /// Gets or sets the maximum accepted size of a single externally + /// resolved document. + /// + public int MaxResolverDocumentBytes { get; set; } = 16 * 1024 * 1024; + + /// + /// Gets or sets the maximum cumulative size of all documents + /// externally resolved for a single top-level conversion. + /// + public long MaxResolverTotalBytes { get; set; } = 128L * 1024 * 1024; + + /// + /// Validates the option values and throws when a limit is not positive. + /// + /// + /// Thrown when any configured limit is not strictly positive. + /// + public void Validate() + { + if (PreservationMode is not ( + WotNodeSetPreservationMode.WhenRequired or + WotNodeSetPreservationMode.Always or + WotNodeSetPreservationMode.Never)) + { + throw new ArgumentOutOfRangeException( + nameof(PreservationMode), + PreservationMode, + "The preservation mode is not defined."); + } + EnsurePositive(MaxJsonDocumentSize, nameof(MaxJsonDocumentSize)); + EnsurePositive(MaxNodeSetSize, nameof(MaxNodeSetSize)); + EnsurePositive(MaxJsonDepth, nameof(MaxJsonDepth)); + EnsurePositive(MaxXmlDepth, nameof(MaxXmlDepth)); + EnsurePositive(MaxNodeCount, nameof(MaxNodeCount)); + EnsurePositive(MaxAffordanceCount, nameof(MaxAffordanceCount)); + EnsurePositive(MaxResolverDepth, nameof(MaxResolverDepth)); + EnsurePositive(MaxResolverDocuments, nameof(MaxResolverDocuments)); + EnsurePositive(MaxResolverDocumentBytes, nameof(MaxResolverDocumentBytes)); + if (MaxResolverTotalBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(MaxResolverTotalBytes), + MaxResolverTotalBytes, + "The configured limit must be a positive value."); + } + } + + /// + /// Projects the aggregate resolver limits configured on this instance + /// onto a suitable for seeding a + /// single per top-level conversion. + /// + /// The equivalent bounded resolution options. + public WotResolverOptions ToResolverOptions() + { + return new WotResolverOptions + { + MaxDepth = MaxResolverDepth, + MaxDocuments = MaxResolverDocuments, + MaxDocumentBytes = MaxResolverDocumentBytes, + MaxTotalBytes = MaxResolverTotalBytes + }; + } + + private static void EnsurePositive(int value, string name) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException( + name, + value, + "The configured limit must be a positive value."); + } + } + } +} diff --git a/src/Opc.Ua.Types/Wot/WotResolver.cs b/src/Opc.Ua.Types/Wot/WotResolver.cs new file mode 100644 index 0000000000..a421179e47 --- /dev/null +++ b/src/Opc.Ua.Types/Wot/WotResolver.cs @@ -0,0 +1,373 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.Wot +{ + /// + /// The kind of external document being resolved. + /// + public enum WotResolutionKind + { + /// A JSON-LD @context document. + Context, + + /// An external DataSchema referenced by uav:externalSchema. + Schema, + + /// A referenced Thing Description or Thing Model document. + Thing + } + + /// + /// Bounded options that govern external document resolution. + /// + public sealed class WotResolverOptions + { + /// Gets or sets the maximum resolution depth. + public int MaxDepth { get; set; } = 16; + + /// Gets or sets the maximum number of documents resolved. + public int MaxDocuments { get; set; } = 256; + + /// Gets or sets the maximum accepted size of a single resolved document. + public int MaxDocumentBytes { get; set; } = 16 * 1024 * 1024; + + /// Gets or sets the maximum total size of all resolved documents. + public long MaxTotalBytes { get; set; } = 128L * 1024 * 1024; + + /// + /// Validates the option values and throws when a limit is not positive. + /// + /// + /// Thrown when any configured limit is not strictly positive. + /// + public void Validate() + { + EnsurePositive(MaxDepth, nameof(MaxDepth)); + EnsurePositive(MaxDocuments, nameof(MaxDocuments)); + EnsurePositive(MaxDocumentBytes, nameof(MaxDocumentBytes)); + if (MaxTotalBytes <= 0) + { + throw new ArgumentOutOfRangeException( + nameof(MaxTotalBytes), + MaxTotalBytes, + "The configured limit must be a positive value."); + } + } + + private static void EnsurePositive(int value, string name) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException( + name, + value, + "The configured limit must be a positive value."); + } + } + } + + /// + /// The result of resolving one external document. + /// + public sealed class WotResolverResult + { + private WotResolverResult( + bool found, + ReadOnlyMemory content, + string? contentType) + { + Found = found; + Content = content; + ContentType = contentType; + } + + /// Gets a value indicating whether the document was found. + public bool Found { get; } + + /// Gets the resolved UTF-8 document bytes. + public ReadOnlyMemory Content { get; } + + /// Gets the media type of the resolved document, if known. + public string? ContentType { get; } + + /// A shared result indicating the document was not found. + public static WotResolverResult NotFound { get; } = + new WotResolverResult(false, ReadOnlyMemory.Empty, null); + + /// Creates a successful result from resolved bytes. + /// The resolved UTF-8 document bytes. + /// The media type of the document, if known. + public static WotResolverResult FromBytes( + ReadOnlyMemory content, + string? contentType = null) + { + return new WotResolverResult(true, content, contentType); + } + } + + /// + /// Resolves external JSON-LD @context documents. Implementations + /// supply their own transport; this library performs no network I/O. + /// + public interface IWotContextResolver + { + /// Resolves a context document by reference. + /// The context reference (absolute or relative IRI). + /// The active resolution context. + /// The resolution result. + WotResolverResult ResolveContext(string reference, WotResolutionContext context); + } + + /// + /// Resolves external DataSchema documents referenced by an affordance. + /// Implementations supply their own transport; this library performs no + /// network I/O. + /// + public interface IWotSchemaResolver + { + /// Resolves a schema document by reference. + /// The schema reference (absolute or relative IRI or path). + /// The active resolution context. + /// The resolution result. + WotResolverResult ResolveSchema(string reference, WotResolutionContext context); + } + + /// + /// Resolves referenced Thing Description or Thing Model documents. + /// Implementations supply their own transport; this library performs no + /// network I/O. + /// + public interface IWotThingResolver + { + /// Resolves a referenced TD/TM document by reference. + /// The document reference (absolute or relative IRI). + /// The active resolution context. + /// The resolution result. + WotResolverResult ResolveThing(string reference, WotResolutionContext context); + } + + /// + /// A resolver that never resolves anything. Use it as an explicit + /// "no external resolution" policy; it performs no I/O. + /// + public sealed class NullWotResolver + : IWotContextResolver, IWotSchemaResolver, IWotThingResolver + { + /// The shared instance. + public static NullWotResolver Instance { get; } = new NullWotResolver(); + + /// + public WotResolverResult ResolveContext(string reference, WotResolutionContext context) + { + return WotResolverResult.NotFound; + } + + /// + public WotResolverResult ResolveSchema(string reference, WotResolutionContext context) + { + return WotResolverResult.NotFound; + } + + /// + public WotResolverResult ResolveThing(string reference, WotResolutionContext context) + { + return WotResolverResult.NotFound; + } + } + + /// + /// Tracks resolution depth, the set of documents currently being resolved + /// (for cycle detection) and cumulative resource usage. A context is + /// created per conversion and is not shared across threads. + /// + public sealed class WotResolutionContext + { + /// + /// Initializes a new instance of the class. + /// + /// The bounded resolution options. + public WotResolutionContext(WotResolverOptions? options = null) + { + m_options = options ?? new WotResolverOptions(); + m_options.Validate(); + m_active = new HashSet(StringComparer.Ordinal); + m_diagnostics = new List(); + } + + /// Gets the bounded resolution options. + public WotResolverOptions Options => m_options; + + /// Gets the current resolution depth. + public int Depth => m_depth; + + /// Gets the number of documents entered so far. + public int DocumentCount => m_documentCount; + + /// Gets the cumulative resolved byte count. + public long TotalBytes => m_totalBytes; + + /// Gets the diagnostics accumulated during resolution. + public IReadOnlyList Diagnostics => m_diagnostics; + + /// + /// Attempts to begin resolving . On success + /// the reference is pushed and the caller must invoke + /// in a finally block. On failure a + /// diagnostic describing the cycle or limit is produced. + /// + /// The kind of document being resolved. + /// The document reference. + /// The blocking diagnostic when the method returns false. + /// true when resolution may proceed. + public bool TryEnter( + WotResolutionKind kind, + string reference, + out WotDiagnostic? diagnostic) + { + if (reference is null) + { + throw new ArgumentNullException(nameof(reference)); + } + + var location = new WotLocation(reference: reference); + + if (m_active.Contains(reference)) + { + diagnostic = Add( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ResolverCycle, + $"External {kind} resolution detected a cycle at '{reference}'.", + location); + return false; + } + + if (m_depth >= m_options.MaxDepth) + { + diagnostic = Add( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ResolverDepthExceeded, + $"External {kind} resolution exceeded the maximum depth of {m_options.MaxDepth}.", + location); + return false; + } + + if (m_documentCount >= m_options.MaxDocuments) + { + diagnostic = Add( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ResolverLimitExceeded, + $"External resolution exceeded the maximum document count of {m_options.MaxDocuments}.", + location); + return false; + } + + m_active.Add(reference); + m_depth++; + m_documentCount++; + diagnostic = null; + return true; + } + + /// + /// Ends resolving . Must be paired with a + /// successful . + /// + /// The document reference. + public void Leave(string reference) + { + if (reference is null) + { + throw new ArgumentNullException(nameof(reference)); + } + if (m_active.Remove(reference)) + { + m_depth--; + } + } + + /// + /// Records that bytes were resolved and + /// verifies the per-document and cumulative byte limits. + /// + /// The document reference. + /// The size of the resolved document. + /// The blocking diagnostic when the method returns false. + /// true when the byte counts remain within the configured limits. + public bool TryAddBytes(string reference, int byteCount, out WotDiagnostic? diagnostic) + { + var location = new WotLocation(reference: reference); + if (byteCount > m_options.MaxDocumentBytes) + { + diagnostic = Add( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ResolverLimitExceeded, + $"Resolved document '{reference}' of {byteCount} bytes exceeds the per-document limit of {m_options.MaxDocumentBytes}.", + location); + return false; + } + + if (m_totalBytes + byteCount > m_options.MaxTotalBytes) + { + diagnostic = Add( + WotDiagnosticSeverity.Error, + WotDiagnosticCode.ResolverLimitExceeded, + $"Cumulative resolved size exceeded the total limit of {m_options.MaxTotalBytes} bytes.", + location); + return false; + } + + m_totalBytes += byteCount; + diagnostic = null; + return true; + } + + private WotDiagnostic Add( + WotDiagnosticSeverity severity, + WotDiagnosticCode code, + string message, + WotLocation location) + { + var diagnostic = new WotDiagnostic(severity, code, message, location); + m_diagnostics.Add(diagnostic); + return diagnostic; + } + + private readonly WotResolverOptions m_options; + private readonly HashSet m_active; + private readonly List m_diagnostics; + private int m_depth; + private int m_documentCount; + private long m_totalBytes; + } +} diff --git a/src/Opc.Ua.Types/Wot/WotVocabulary.cs b/src/Opc.Ua.Types/Wot/WotVocabulary.cs new file mode 100644 index 0000000000..ec26fdafab --- /dev/null +++ b/src/Opc.Ua.Types/Wot/WotVocabulary.cs @@ -0,0 +1,238 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.Wot +{ + /// + /// Well-known identifiers, reference types, modelling rules and DataType + /// mappings used by the WoT/NodeSet conversion. Kept in one place so that + /// the numeric OPC UA base-namespace NodeIds are not scattered. + /// + internal static class WotVocabulary + { + public const string VocabularyNamespace = "http://opcfoundation.org/UA/WoT-Binding/"; + public const string OpcUaNamespace = "http://opcfoundation.org/UA/"; + public const string NodeSetXmlNamespace = "http://opcfoundation.org/UA/2011/03/UANodeSet.xsd"; + public const string NodeSetContentType = "application/opcua-nodeset+xml"; + public const string Base64Encoding = "base64"; + public const string EnvelopeType = "uav:nodeSet"; + public const string EnvelopePreservationType = "uav:NodeSet2Preservation"; + public const string ProfileVersion = "1.0"; + public const string ThingModelType = "tm:ThingModel"; + public const string WotContext = "https://www.w3.org/2022/wot/td/v1.1"; + + // Reference types (base namespace). + public const string HasSubtype = "i=45"; + public const string HasProperty = "i=46"; + public const string HasComponent = "i=47"; + public const string HasOrderedComponent = "i=49"; + public const string Organizes = "i=35"; + public const string HasTypeDefinition = "i=40"; + public const string HasModellingRule = "i=37"; + public const string GeneratesEvent = "i=41"; + + // Type-annotation term for an event affordance projecting a UA EventType. + public const string EventTypeAnnotation = "uav:eventType"; + + private static readonly Dictionary s_referenceTypeNameToNodeId = + new(StringComparer.Ordinal) + { + ["Organizes"] = Organizes, + ["HasModellingRule"] = HasModellingRule, + ["HasTypeDefinition"] = HasTypeDefinition, + ["GeneratesEvent"] = GeneratesEvent, + ["HasSubtype"] = HasSubtype, + ["HasProperty"] = HasProperty, + ["HasComponent"] = HasComponent, + ["HasOrderedComponent"] = HasOrderedComponent + }; + + private static readonly Dictionary s_referenceTypeNodeIdToName = + new(StringComparer.Ordinal) + { + [Organizes] = "Organizes", + [HasModellingRule] = "HasModellingRule", + [HasTypeDefinition] = "HasTypeDefinition", + [GeneratesEvent] = "GeneratesEvent", + [HasSubtype] = "HasSubtype", + [HasProperty] = "HasProperty", + [HasComponent] = "HasComponent", + [HasOrderedComponent] = "HasOrderedComponent" + }; + + // HasComponent subtypes (base namespace) that carry stronger semantics + // than plain HasComponent and must be pinned by a link whose rel is + // the ReferenceType model name (WoT Binding Section 5.3). Keyed by both the reference-type + // BrowseName and its base-namespace NodeId; the value is the canonical + // base-namespace ExpandedNodeId used for the typed link's uav:refId. + // HasComponent and HasProperty are intentionally excluded: they are the + // baseline parent-child forms surfaced directly as affordances. + private static readonly Dictionary s_hasComponentSubtypes = + new(StringComparer.Ordinal) + { + ["HasOrderedComponent"] = HasOrderedComponent, + [HasOrderedComponent] = HasOrderedComponent + }; + + // Base types (base namespace). + public const string BaseObjectType = "i=58"; + public const string BaseVariableType = "i=62"; + public const string BaseDataVariableType = "i=63"; + public const string PropertyType = "i=68"; + public const string BaseEventType = "i=2041"; + public const string BaseDataType = "i=24"; + + // Modelling rules (base namespace). + public const string ModellingRuleMandatory = "i=78"; + public const string ModellingRuleOptional = "i=80"; + public const string ModellingRuleMandatoryPlaceholder = "i=11508"; + public const string ModellingRuleOptionalPlaceholder = "i=11509"; + + private static readonly Dictionary s_modellingRuleToNodeId = + new(StringComparer.Ordinal) + { + ["Mandatory"] = ModellingRuleMandatory, + ["Optional"] = ModellingRuleOptional, + ["MandatoryPlaceholder"] = ModellingRuleMandatoryPlaceholder, + ["OptionalPlaceholder"] = ModellingRuleOptionalPlaceholder + }; + + private static readonly Dictionary s_nodeIdToModellingRule = + new(StringComparer.Ordinal) + { + [ModellingRuleMandatory] = "Mandatory", + [ModellingRuleOptional] = "Optional", + [ModellingRuleMandatoryPlaceholder] = "MandatoryPlaceholder", + [ModellingRuleOptionalPlaceholder] = "OptionalPlaceholder" + }; + + private static readonly Dictionary s_jsonTypeToDataType = + new(StringComparer.Ordinal) + { + ["boolean"] = "i=1", + ["integer"] = "i=8", + ["number"] = "i=11", + ["string"] = "i=12", + ["object"] = "i=22", + ["null"] = "i=24" + }; + + public static bool TryGetModellingRuleNodeId(string modellingRule, out string nodeId) + { + return s_modellingRuleToNodeId.TryGetValue(modellingRule, out nodeId!); + } + + public static bool TryGetModellingRuleName(string nodeId, out string modellingRule) + { + return s_nodeIdToModellingRule.TryGetValue(nodeId, out modellingRule!); + } + + public static string MapJsonTypeToDataType(string? jsonType) + { + if (jsonType is not null && + s_jsonTypeToDataType.TryGetValue(jsonType, out string? dataType)) + { + return dataType; + } + return BaseDataType; + } + + public static bool IsModellingRule(string modellingRule) + { + return s_modellingRuleToNodeId.ContainsKey(modellingRule); + } + + /// + /// Determines whether a reference type (given as a BrowseName or a NodeId) + /// is a HasComponent subtype whose exact semantics must be pinned by a + /// typed Reference link, and returns the canonical + /// base-namespace ExpandedNodeId to use for the link's uav:refId. + /// + public static bool TryGetHasComponentSubtype(string? referenceType, out string subtypeNodeId) + { + if (referenceType is not null && + s_hasComponentSubtypes.TryGetValue(referenceType, out subtypeNodeId!)) + { + return true; + } + subtypeNodeId = string.Empty; + return false; + } + + public static bool TryGetReferenceTypeNodeId( + string? browseName, + out string nodeId) + { + if (browseName is not null && + s_referenceTypeNameToNodeId.TryGetValue(browseName, out nodeId!)) + { + return true; + } + nodeId = string.Empty; + return false; + } + + public static bool TryGetReferenceTypeBrowseName( + string? referenceType, + out string browseName) + { + if (referenceType is not null) + { + if (s_referenceTypeNodeIdToName.TryGetValue( + referenceType, + out browseName!)) + { + return true; + } + if (s_referenceTypeNameToNodeId.ContainsKey(referenceType)) + { + browseName = referenceType; + return true; + } + } + browseName = string.Empty; + return false; + } + + public static string FormatUInt(uint value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + + public static string FormatInt(int value) + { + return value.ToString(CultureInfo.InvariantCulture); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Http/HttpStatusMapper.cs b/src/Opc.Ua.WotCon.Binding.Http/HttpStatusMapper.cs new file mode 100644 index 0000000000..a7b51b9955 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/HttpStatusMapper.cs @@ -0,0 +1,60 @@ +/* ======================================================================== + * 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.Net; + +namespace Opc.Ua.WotCon.Binding.Http +{ + /// Maps HTTP status codes to OPC UA values. + internal static class HttpStatusMapper + { + public static StatusCode Map(HttpStatusCode status) + { + int code = (int)status; + if (code is >= 200 and < 300) + { + return StatusCodes.Good; + } + return status switch + { + HttpStatusCode.BadRequest => StatusCodes.BadInvalidArgument, + HttpStatusCode.Unauthorized => StatusCodes.BadUserAccessDenied, + HttpStatusCode.Forbidden => StatusCodes.BadUserAccessDenied, + HttpStatusCode.NotFound => StatusCodes.BadNodeIdUnknown, + HttpStatusCode.MethodNotAllowed => StatusCodes.BadNotSupported, + HttpStatusCode.RequestTimeout => StatusCodes.BadTimeout, + HttpStatusCode.Conflict => StatusCodes.BadInvalidState, + HttpStatusCode.NotImplemented => StatusCodes.BadNotImplemented, + HttpStatusCode.ServiceUnavailable => StatusCodes.BadServerHalted, + HttpStatusCode.GatewayTimeout => StatusCodes.BadTimeout, + _ => code >= 500 ? StatusCodes.BadInternalError : StatusCodes.BadUnexpectedError + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingChannel.cs b/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingChannel.cs new file mode 100644 index 0000000000..f84b4bba7c --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingChannel.cs @@ -0,0 +1,491 @@ +/* ======================================================================== + * 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.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding.Http +{ + /// + /// A live HTTP binding channel. It executes read (GET), write (PUT/method), + /// action (POST/method), observe and event operations with bounded timeouts + /// and payload sizes, cooperative cancellation, HTTP-to- + /// mapping and credential-provider-driven authentication. + /// + internal sealed class HttpWotBindingChannel : IWotBindingChannel + { + public HttpWotBindingChannel( + HttpClient client, + bool ownsClient, + bool manualRedirects, + WotCompiledForm form, + WotExecutorContext context, + HttpWotBindingOptions options) + { + m_client = client; + m_ownsClient = ownsClient; + m_manualRedirects = manualRedirects; + m_form = form; + m_context = context; + m_options = options; + context.Codecs.TrySelect(form.Payload.ContentType, out m_codec); + m_baseTarget = form.Addressing.Target; + } + + public WotCompiledForm Form => m_form; + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + (StatusCode status, byte[] body, string? error) = + await SendAsync(HttpMethod.Get, null, cancellationToken).ConfigureAwait(false); + if (!StatusCode.IsGood(status)) + { + return new WotReadResult(status, DataValue.FromStatusCode(status), error); + } + WotDecodeResult decoded = m_codec.Decode(body, m_form.Payload); + if (!decoded.Success) + { + return new WotReadResult( + StatusCodes.BadDecodingError, DataValue.FromStatusCode(StatusCodes.BadDecodingError), decoded.Error); + } + return new WotReadResult( + StatusCodes.Good, + new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + WotEncodeResult encoded = m_codec.Encode(value.WrappedValue, m_form.Payload); + if (!encoded.Success) + { + return new WotWriteResult(StatusCodes.BadEncodingError, encoded.Error); + } + HttpMethod method = ResolveMethod("PUT"); + (StatusCode status, _, string? error) = + await SendAsync(method, encoded.Data, cancellationToken).ConfigureAwait(false); + return new WotWriteResult(status, error); + } + + public async ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + ReadOnlyMemory? content = null; + if (inputs is { Count: > 0 }) + { + WotEncodeResult encoded = m_codec.Encode(inputs[0], m_form.Payload); + if (!encoded.Success) + { + return new WotInvokeResult(StatusCodes.BadEncodingError, null, encoded.Error); + } + content = encoded.Data; + } + HttpMethod method = ResolveMethod("POST"); + (StatusCode status, byte[] body, string? error) = + await SendAsync(method, content, cancellationToken).ConfigureAwait(false); + if (!StatusCode.IsGood(status)) + { + return new WotInvokeResult(status, null, error); + } + if (body.Length == 0) + { + return new WotInvokeResult(StatusCodes.Good, Array.Empty()); + } + WotDecodeResult decoded = m_codec.Decode(body, m_form.Payload); + var output = new DataValue( + decoded.Success ? decoded.Value : Variant.Null, + decoded.Success ? StatusCodes.Good : StatusCodes.BadDecodingError, + DateTimeUtc.Now, DateTimeUtc.Now); + return new WotInvokeResult(StatusCodes.Good, new[] { output }); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription( + m_form, + async token => + { + WotReadResult result = await ReadAsync(token).ConfigureAwait(false); + if (result.Success) + { + onNotification(new WotNotification(result.Value)); + } + }, + m_options.ObserveInterval, + // A transient poll fault is reported as a Bad-status notification + // so consumers observe the fault without the poll loop faulting. + onError: _ => onNotification(new WotNotification( + DataValue.FromStatusCode(StatusCodes.BadCommunicationError)))); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + => ObserveAsync(onEvent, cancellationToken); + + public ValueTask DisposeAsync() + { + if (m_ownsClient) + { + m_client.Dispose(); + } + return default; + } + + private HttpMethod ResolveMethod(string fallback) + { + string method = string.IsNullOrEmpty(m_form.OperationInfo.Method) + ? fallback : m_form.OperationInfo.Method; + return new HttpMethod(method.ToUpperInvariant()); + } + + private async ValueTask<(StatusCode Status, byte[] Body, string? Error)> SendAsync( + HttpMethod method, ReadOnlyMemory? content, CancellationToken cancellationToken) + { + await EnsureCredentialAsync(cancellationToken).ConfigureAwait(false); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_context.Bounds.DefaultTimeout); + try + { + if (!Uri.TryCreate(m_baseTarget, UriKind.Absolute, out Uri? current) || current is null) + { + return (StatusCodes.BadInvalidArgument, Array.Empty(), + "The HTTP target is not a valid absolute URI."); + } + Uri origin = current; + HttpMethod currentMethod = method; + ReadOnlyMemory? currentContent = content; + int redirectsRemaining = m_manualRedirects ? Math.Max(0, m_options.MaxAutomaticRedirects) : 0; + var visited = new HashSet(StringComparer.OrdinalIgnoreCase); + while (true) + { + visited.Add(current.AbsoluteUri); + // Custom header / query credentials are only applied while the + // request stays on the original origin; a cross-origin redirect + // drops them so they never leak to a different host. + bool sameOrigin = IsSameOrigin(origin, current); + Uri requestUri = sameOrigin ? AppendCredentialQuery(current) : current; + HopResult hop = await SendOnceAsync( + currentMethod, requestUri, sameOrigin, currentContent, timeout.Token).ConfigureAwait(false); + + if (hop.Redirect is null) + { + return (hop.Status, hop.Body, hop.Error); + } + + if (redirectsRemaining <= 0) + { + return (StatusCodes.BadCommunicationError, Array.Empty(), + "The HTTP redirect limit was exceeded."); + } + Uri? next = ResolveRedirectTarget(current, hop.Location, out string? redirectError); + if (next is null) + { + return (StatusCodes.BadSecurityChecksFailed, Array.Empty(), redirectError); + } + if (visited.Contains(next.AbsoluteUri)) + { + return (StatusCodes.BadCommunicationError, Array.Empty(), + "The HTTP redirect chain contains a loop."); + } + redirectsRemaining--; + // 303 (and, per browser convention, 301/302) turn the follow-up + // request into a bodyless GET; 307/308 preserve method and body. + if (hop.Redirect is System.Net.HttpStatusCode.MovedPermanently or + System.Net.HttpStatusCode.Found or System.Net.HttpStatusCode.SeeOther) + { + currentMethod = HttpMethod.Get; + currentContent = null; + } + current = next; + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return (StatusCodes.BadTimeout, Array.Empty(), "The HTTP request timed out."); + } + catch (HttpRequestException ex) + { + return (StatusCodes.BadCommunicationError, Array.Empty(), ex.Message); + } + catch (InvalidOperationException ex) + { + return (StatusCodes.BadEncodingLimitsExceeded, Array.Empty(), ex.Message); + } + } + + /// The outcome of a single request hop: either a terminal result or a redirect. + private readonly struct HopResult + { + private HopResult( + System.Net.HttpStatusCode? redirect, Uri? location, + StatusCode status, byte[] body, string? error) + { + Redirect = redirect; + Location = location; + Status = status; + Body = body; + Error = error; + } + + public System.Net.HttpStatusCode? Redirect { get; } + + public Uri? Location { get; } + + public StatusCode Status { get; } + + public byte[] Body { get; } + + public string? Error { get; } + + public static HopResult Terminal(StatusCode status, byte[] body, string? error) + => new HopResult(null, null, status, body, error); + + public static HopResult RedirectTo(System.Net.HttpStatusCode redirect, Uri? location) + => new HopResult(redirect, location, StatusCodes.Good, Array.Empty(), null); + } + + private async Task SendOnceAsync( + HttpMethod method, Uri requestUri, bool sameOrigin, + ReadOnlyMemory? content, CancellationToken cancellationToken) + { + using var request = new HttpRequestMessage(method, requestUri); + ApplyHeaders(request, sameOrigin); + if (content is { } body && method != HttpMethod.Get && method != HttpMethod.Head) + { + var byteContent = new ByteArrayContent(body.ToArray()); + if (!string.IsNullOrEmpty(m_form.Payload.ContentType)) + { + byteContent.Headers.TryAddWithoutValidation("Content-Type", m_form.Payload.ContentType); + } + request.Content = byteContent; + } + + using HttpResponseMessage response = await m_client + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken) + .ConfigureAwait(false); + + if (m_manualRedirects && IsRedirect(response.StatusCode)) + { + return HopResult.RedirectTo(response.StatusCode, response.Headers.Location); + } + + StatusCode status = HttpStatusMapper.Map(response.StatusCode); + if (!response.IsSuccessStatusCode) + { + return HopResult.Terminal(status, Array.Empty(), + $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}"); + } + byte[] payload = await ReadBoundedAsync(response, cancellationToken).ConfigureAwait(false); + return HopResult.Terminal(StatusCodes.Good, payload, null); + } + + private static bool IsRedirect(System.Net.HttpStatusCode status) + => status is System.Net.HttpStatusCode.MovedPermanently or + System.Net.HttpStatusCode.Found or + System.Net.HttpStatusCode.SeeOther or + System.Net.HttpStatusCode.TemporaryRedirect or + System.Net.HttpStatusCode.PermanentRedirect; + + private Uri? ResolveRedirectTarget(Uri current, Uri? location, out string? error) + { + error = null; + if (location is null) + { + error = "The HTTP redirect response carried no Location header."; + return null; + } + if (!location.IsAbsoluteUri) + { + location = new Uri(current, location); + } + if (!string.Equals(location.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !string.Equals(location.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + error = $"The HTTP redirect targets a disallowed scheme '{location.Scheme}'."; + return null; + } + if (string.Equals(current.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(location.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !m_options.AllowInsecureRedirectDowngrade) + { + error = "The HTTP redirect downgrades https to http, which is refused."; + return null; + } + return location; + } + + private static bool IsSameOrigin(Uri a, Uri b) + => string.Equals(a.Scheme, b.Scheme, StringComparison.OrdinalIgnoreCase) && + string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase) && + a.Port == b.Port; + + private async Task ReadBoundedAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var buffer = new MemoryStream(); + byte[] chunk = new byte[8192]; + int max = m_context.Bounds.MaxPayloadBytes; + int total = 0; + int read; + while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken).ConfigureAwait(false)) > 0) + { + total += read; + if (total > max) + { + throw new InvalidOperationException( + $"The HTTP response exceeds the maximum payload size of {max} bytes."); + } + buffer.Write(chunk, 0, read); + } + return buffer.ToArray(); + } + + private async ValueTask EnsureCredentialAsync(CancellationToken cancellationToken) + { + Task task; + lock (m_credentialLock) + { + // Start (or reuse) a single shared resolution. Concurrent callers + // all await the same task, so the resolved credential and the + // effective target are published exactly once and no request is + // ever sent before that state is ready. + task = m_credentialTask ??= ResolveCredentialAsync(cancellationToken); + } + try + { + await task.ConfigureAwait(false); + } + catch + { + // Failure retry policy: a failed (or cancelled) resolution is not + // cached, so the next request re-attempts resolution instead of + // being permanently wedged on the fault. + lock (m_credentialLock) + { + if (ReferenceEquals(m_credentialTask, task)) + { + m_credentialTask = null; + } + } + throw; + } + } + + private async Task ResolveCredentialAsync(CancellationToken cancellationToken) + { + WotCredential? credential = null; + if (!m_form.Security.IsEmpty) + { + credential = await m_context.Credentials + .ResolveAsync(m_form.Security[0], cancellationToken).ConfigureAwait(false); + } + // Publish the resolved credential only after resolution has completed. A + // caller reads m_credential in SendAsync only after awaiting the shared + // task, so it can never observe a half-initialized state or send a + // request without the resolved credential applied. + m_credential = credential; + } + + private void ApplyHeaders(HttpRequestMessage request, bool includeCredentials) + { + // A cross-origin redirect must not carry any custom (potentially + // credential-bearing) header, so both the caller's default headers and + // the resolved credential headers are only applied on the original + // origin. + if (!includeCredentials) + { + return; + } + if (m_options.DefaultHeaders is { Count: > 0 }) + { + foreach (KeyValuePair header in m_options.DefaultHeaders) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + if (m_credential is { } credential) + { + foreach (KeyValuePair header in credential.Headers) + { + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + } + } + + private Uri AppendCredentialQuery(Uri target) + { + WotCredential? credential = m_credential; + if (credential is null || credential.QueryParameters.Count == 0) + { + return target; + } + var query = new StringBuilder(); + foreach (KeyValuePair parameter in credential.QueryParameters) + { + if (query.Length > 0) + { + query.Append('&'); + } + query.Append(Uri.EscapeDataString(parameter.Key)).Append('=') + .Append(Uri.EscapeDataString(parameter.Value)); + } + var builder = new UriBuilder(target); + builder.Query = string.IsNullOrEmpty(builder.Query) + ? query.ToString() + : builder.Query.TrimStart('?') + "&" + query; + return builder.Uri; + } + + private readonly HttpClient m_client; + private readonly bool m_ownsClient; + private readonly bool m_manualRedirects; + private readonly WotCompiledForm m_form; + private readonly WotExecutorContext m_context; + private readonly HttpWotBindingOptions m_options; + private readonly IWotPayloadCodec m_codec; + private readonly string m_baseTarget; + private WotCredential? m_credential; + private readonly object m_credentialLock = new object(); + private Task? m_credentialTask; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingExecutor.cs new file mode 100644 index 0000000000..19f21a7537 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingExecutor.cs @@ -0,0 +1,118 @@ +/* ======================================================================== + * 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.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Binding.Http +{ + /// + /// Executes HTTP / HTTPS WoT binding forms compiled by the + /// . It opens a per-form + /// using an injectable + /// factory. + /// + public sealed class HttpWotBindingExecutor : IWotBindingExecutor + { + /// Initializes a new HTTP executor. + public HttpWotBindingExecutor(HttpWotBindingOptions? options = null) + { + m_options = options ?? new HttpWotBindingOptions(); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.http", "1.1", HttpBindingPlanner.BindingUri, "W3C WoT HTTP Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + { + if (form is null) + { + return false; + } + return string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal) && + (string.Equals(form.Endpoint.Scheme, "http", StringComparison.OrdinalIgnoreCase) || + string.Equals(form.Endpoint.Scheme, "https", StringComparison.OrdinalIgnoreCase)); + } + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The client and channel are owned by the returned channel, disposed via DisposeAsync.")] + public ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + bool ownsClient = m_options.ClientFactory is null; + if (!ownsClient && + FormCarriesCredentials(form) && + !m_options.CallerClientHandlesRedirectSafety) + { + // Fail closed: the executor cannot control a caller-supplied client's + // redirect behavior, so a credential-bearing form could leak its + // custom header / query credentials if that client auto-redirects + // across origins. Require the caller to explicitly confirm the client + // handles redirects safely. + throw new InvalidOperationException( + "A caller-supplied HttpClient cannot execute a credential-bearing HTTP form unless " + + "HttpWotBindingOptions.CallerClientHandlesRedirectSafety is set. The supplied client must " + + "disable automatic redirects, or follow them without forwarding credentials across origins, " + + "to avoid leaking custom header / query credentials on a redirect."); + } + HttpClient client = ownsClient + ? new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + CheckCertificateRevocationList = true + }) + : m_options.ClientFactory!.Invoke(); + IWotBindingChannel channel = new HttpWotBindingChannel( + client, ownsClient, manualRedirects: ownsClient, form, context, m_options); + return new ValueTask(channel); + } + + private static bool FormCarriesCredentials(WotCompiledForm form) + => !form.Security.IsDefaultOrEmpty && + form.Security.Any(reference => reference.Scheme != WotSecurityScheme.NoSecurity); + + private readonly HttpWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingOptions.cs b/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingOptions.cs new file mode 100644 index 0000000000..b57c70dd23 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/HttpWotBindingOptions.cs @@ -0,0 +1,88 @@ +/* ======================================================================== + * 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.Net.Http; + +namespace Opc.Ua.WotCon.Binding.Http +{ + /// + /// Options for the HTTP WoT binding executor. The client factory is injectable + /// so callers can supply a pooled, mutually-authenticated or test + /// ; when none is supplied the executor owns a private + /// client. Default headers are applied to every request in addition to any + /// credential the provider resolves. + /// + public sealed class HttpWotBindingOptions + { + /// + /// Gets or sets the factory that supplies the . When + /// null the executor creates and owns a private client whose handler + /// disables automatic redirects, so the executor can apply a bounded, + /// origin-aware redirect policy that never leaks credentials across origins. + /// A supplied client is treated as caller-owned and is never disposed by + /// the executor. + /// + public Func? ClientFactory { get; set; } + + /// Gets or sets default headers applied to every request. + public IReadOnlyDictionary? DefaultHeaders { get; set; } + + /// Gets or sets the poll interval used for observe / event operations. + public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the maximum number of redirects the executor-owned client + /// follows for a single request. The default is 5; 0 disables + /// redirect following entirely. Custom header and query credentials are + /// stripped whenever a redirect crosses to a different origin. + /// + public int MaxAutomaticRedirects { get; set; } = 5; + + /// + /// Gets or sets whether the executor-owned client may follow a redirect that + /// downgrades the scheme from https to http. The default is + /// false: an insecure downgrade is refused. + /// + public bool AllowInsecureRedirectDowngrade { get; set; } + + /// + /// Gets or sets whether a caller-supplied is trusted + /// to handle redirects safely for credential-bearing forms. The default is + /// false: activating a credential-bearing form on a caller-supplied + /// client fails closed, because the executor cannot guarantee that client + /// disables automatic redirects (which could leak custom header or query + /// credentials across origins). Set to true only when the supplied + /// client is known to disable automatic redirects, or to follow them without + /// forwarding credentials to a different origin. + /// + public bool CallerClientHandlesRedirectSafety { get; set; } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Http/NugetREADME.md b/src/Opc.Ua.WotCon.Binding.Http/NugetREADME.md new file mode 100644 index 0000000000..2b8e74f6f3 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/NugetREADME.md @@ -0,0 +1,25 @@ +# OPC UA WoT Connectivity — HTTP Executor + +`OPCFoundation.NetStandard.Opc.Ua.WotCon.Binding.Http` executes HTTP / HTTPS +WoT Connectivity binding forms compiled by the HTTP planner in +`Opc.Ua.WotCon.Binding`. + +It provides an `HttpClient`-based executor for read / write / action / observe / +event operations with bounded timeouts and payload sizes, cooperative +cancellation, HTTP-to-`StatusCode` mapping, an injectable client factory and +credential-provider-driven authentication headers. + +## Redirect-safe credentials + +- The executor-owned client disables automatic redirects and applies a bounded, + origin-aware redirect policy. Custom header and query credentials are dropped + whenever a redirect crosses to a different origin, redirect loops and non + `http(s)` schemes are refused, an `https` → `http` downgrade is + refused (unless `AllowInsecureRedirectDowngrade` is set) and the number of + redirects is capped by `MaxAutomaticRedirects` (default 5). +- A caller-supplied `HttpClient` used with a credential-bearing form fails closed + unless `HttpWotBindingOptions.CallerClientHandlesRedirectSafety` is set to + confirm the client disables automatic redirects, or follows them without + forwarding credentials across origins. + +Register it with `builder.AddHttpWotBinding()`. diff --git a/src/Opc.Ua.WotCon.Binding.Http/Opc.Ua.WotCon.Binding.Http.csproj b/src/Opc.Ua.WotCon.Binding.Http/Opc.Ua.WotCon.Binding.Http.csproj new file mode 100644 index 0000000000..232da96b4b --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/Opc.Ua.WotCon.Binding.Http.csproj @@ -0,0 +1,28 @@ + + + $(AssemblyPrefix).WotCon.Binding.Http + net8.0;net9.0;net10.0 + $(CustomTestTarget) + $(PackagePrefix).Opc.Ua.WotCon.Binding.Http + Opc.Ua.WotCon.Binding.Http + $(NoWarn);CS1591 + enable + HTTP protocol executor for OPC UA WoT Connectivity binding forms + true + NugetREADME.md + true + true + + + + + + $(PackageId).Debug + + + + + + + + diff --git a/src/Opc.Ua.WotCon.Binding.Http/OpcUaHttpWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Binding.Http/OpcUaHttpWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..99960f253c --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/OpcUaHttpWotBindingBuilderExtensions.cs @@ -0,0 +1,60 @@ +/* ======================================================================== + * 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 Opc.Ua; +using Opc.Ua.WotCon.Binding.Http; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the HTTP WoT binding + /// executor alongside the shipped planner binders. + /// + public static class OpcUaHttpWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the HTTP executor, so + /// HTTP binding forms are validated, compiled and executable. + /// + public static IOpcUaBuilder AddHttpWotBinding( + this IOpcUaBuilder builder, Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + var options = new HttpWotBindingOptions(); + configure?.Invoke(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new HttpWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Http/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Binding.Http/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Http/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusAddressing.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusAddressing.cs new file mode 100644 index 0000000000..a404bf8259 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusAddressing.cs @@ -0,0 +1,134 @@ +/* ======================================================================== + * 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.Immutable; +using System.Globalization; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// + /// The Modbus register addressing parsed from a compiled form, re-validated by + /// the executor before the values are narrowed to / + /// . Although the planner already enforces the same bounds, + /// the executor independently re-checks them so a hand-built or tampered + /// compiled form can never silently truncate an out-of-range address or + /// quantity through an unchecked cast. + /// + internal readonly struct ModbusAddressing + { + private const int MaxAddress = 65535; + + private ModbusAddressing( + string entity, ushort address, ushort quantity, byte unitId, + string type, bool msbFirst, bool mswFirst) + { + Entity = entity; + Address = address; + Quantity = quantity; + UnitId = unitId; + Type = type; + MsbFirst = msbFirst; + MswFirst = mswFirst; + } + + public string Entity { get; } + + public ushort Address { get; } + + public ushort Quantity { get; } + + public byte UnitId { get; } + + public string Type { get; } + + public bool MsbFirst { get; } + + public bool MswFirst { get; } + + /// + /// Parses and validates the addressing carried by a compiled Modbus form, + /// throwing when the address, + /// quantity, addressed range or unit id is out of the Modbus bounds. + /// + public static ModbusAddressing FromForm(WotCompiledForm form) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + ImmutableDictionary map = form.Addressing.Metadata; + string entity = GetString(map, "entity", "holdingRegister"); + int address = GetInt(map, "address", 0); + int quantity = Math.Max(1, GetInt(map, "quantity", 1)); + int unitId = GetInt(map, "unitId", 1); + + if (address is < 0 or > MaxAddress) + { + throw new ArgumentOutOfRangeException( + nameof(form), address, $"The Modbus address must be between 0 and {MaxAddress}."); + } + if (quantity is < 1 or > ushort.MaxValue) + { + throw new ArgumentOutOfRangeException( + nameof(form), quantity, $"The Modbus quantity must be between 1 and {ushort.MaxValue}."); + } + if (address + quantity - 1 > MaxAddress) + { + throw new ArgumentOutOfRangeException( + nameof(form), address, + $"The Modbus range starting at {address} for {quantity} items exceeds the maximum " + + $"address {MaxAddress}."); + } + if (unitId is < 0 or > 255) + { + throw new ArgumentOutOfRangeException( + nameof(form), unitId, "The Modbus unit id must be between 0 and 255."); + } + + string type = GetString(form.Payload.Metadata, "type", "uint16"); + bool msbFirst = GetBool(form.Payload.Metadata, "mostSignificantByte", true); + bool mswFirst = GetBool(form.Payload.Metadata, "mostSignificantWord", true); + + return new ModbusAddressing( + entity, (ushort)address, (ushort)quantity, (byte)unitId, type, msbFirst, mswFirst); + } + + private static string GetString(ImmutableDictionary map, string key, string fallback) + => map.TryGetValue(key, out string? value) && !string.IsNullOrEmpty(value) ? value : fallback; + + private static int GetInt(ImmutableDictionary map, string key, int fallback) + => map.TryGetValue(key, out string? value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result) + ? result : fallback; + + private static bool GetBool(ImmutableDictionary map, string key, bool fallback) + => map.TryGetValue(key, out string? value) && bool.TryParse(value, out bool result) ? result : fallback; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusDataConverter.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusDataConverter.cs new file mode 100644 index 0000000000..d7d6108175 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusDataConverter.cs @@ -0,0 +1,207 @@ +/* ======================================================================== + * 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.Globalization; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// + /// Converts between Modbus register words and OPC UA values, honouring the + /// modv:type data type and the byte / word order flags + /// (modv:mostSignificantByte / modv:mostSignificantWord). + /// + internal static class ModbusDataConverter + { + public static int RegisterCount(string type) + { + return Normalize(type) switch + { + "int16" or "uint16" => 1, + "int32" or "uint32" or "float32" => 2, + "int64" or "uint64" or "float64" => 4, + _ => 1 + }; + } + + public static Variant ToVariant(ushort[] registers, string type, bool msbFirst, bool mswFirst) + { + string normalized = Normalize(type); + int needed = RegisterCount(normalized); + if (registers.Length < needed) + { + throw new ModbusException( + $"The Modbus data type '{type}' requires {needed} registers but {registers.Length} were read."); + } + var slice = new ushort[needed]; + Array.Copy(registers, slice, needed); + byte[] bigEndian = Canonical(slice, msbFirst, mswFirst); + byte[] host = ToHostOrder(bigEndian); + return normalized switch + { + "int16" => new Variant(BitConverter.ToInt16(host, 0)), + "uint16" => new Variant(BitConverter.ToUInt16(host, 0)), + "int32" => new Variant(BitConverter.ToInt32(host, 0)), + "uint32" => new Variant(BitConverter.ToUInt32(host, 0)), + "float32" => new Variant(BitConverter.ToSingle(host, 0)), + "int64" => new Variant(BitConverter.ToInt64(host, 0)), + "uint64" => new Variant(BitConverter.ToUInt64(host, 0)), + "float64" => new Variant(BitConverter.ToDouble(host, 0)), + _ => new Variant(BitConverter.ToUInt16(host, 0)) + }; + } + + public static ushort[] ToRegisters(Variant value, string type, bool msbFirst, bool mswFirst) + { + string normalized = Normalize(type); + byte[] bigEndian = normalized switch + { + "int16" => BigEndianBytes(BitConverter.GetBytes(ToInt16(value))), + "uint16" => BigEndianBytes(BitConverter.GetBytes(ToUInt16(value))), + "int32" => BigEndianBytes(BitConverter.GetBytes(Convert.ToInt32(BoxOf(value), CultureInfo.InvariantCulture))), + "uint32" => BigEndianBytes(BitConverter.GetBytes(Convert.ToUInt32(BoxOf(value), CultureInfo.InvariantCulture))), + "float32" => BigEndianBytes(BitConverter.GetBytes(Convert.ToSingle(BoxOf(value), CultureInfo.InvariantCulture))), + "int64" => BigEndianBytes(BitConverter.GetBytes(Convert.ToInt64(BoxOf(value), CultureInfo.InvariantCulture))), + "uint64" => BigEndianBytes(BitConverter.GetBytes(Convert.ToUInt64(BoxOf(value), CultureInfo.InvariantCulture))), + "float64" => BigEndianBytes(BitConverter.GetBytes(Convert.ToDouble(BoxOf(value), CultureInfo.InvariantCulture))), + _ => BigEndianBytes(BitConverter.GetBytes(ToUInt16(value))) + }; + return FromCanonical(bigEndian, msbFirst, mswFirst); + } + + private static byte[] Canonical(ushort[] registers, bool msbFirst, bool mswFirst) + { + int words = registers.Length; + var wordBytes = new byte[words][]; + for (int i = 0; i < words; i++) + { + byte hi = (byte)(registers[i] >> 8); + byte lo = (byte)(registers[i] & 0xFF); + wordBytes[i] = msbFirst ? new[] { hi, lo } : new[] { lo, hi }; + } + if (!mswFirst) + { + Array.Reverse(wordBytes); + } + byte[] result = new byte[words * 2]; + for (int i = 0; i < words; i++) + { + result[i * 2] = wordBytes[i][0]; + result[(i * 2) + 1] = wordBytes[i][1]; + } + return result; + } + + private static ushort[] FromCanonical(byte[] bigEndian, bool msbFirst, bool mswFirst) + { + int words = bigEndian.Length / 2; + var wordBytes = new byte[words][]; + for (int i = 0; i < words; i++) + { + wordBytes[i] = new[] { bigEndian[i * 2], bigEndian[(i * 2) + 1] }; + } + if (!mswFirst) + { + Array.Reverse(wordBytes); + } + var registers = new ushort[words]; + for (int i = 0; i < words; i++) + { + byte b0 = wordBytes[i][0]; + byte b1 = wordBytes[i][1]; + byte hi = msbFirst ? b0 : b1; + byte lo = msbFirst ? b1 : b0; + registers[i] = (ushort)((hi << 8) | lo); + } + return registers; + } + + private static byte[] ToHostOrder(byte[] bigEndian) + { + byte[] copy = (byte[])bigEndian.Clone(); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(copy); + } + return copy; + } + + private static byte[] BigEndianBytes(byte[] hostOrder) + { + byte[] copy = (byte[])hostOrder.Clone(); + if (BitConverter.IsLittleEndian) + { + Array.Reverse(copy); + } + return copy; + } + + private static object BoxOf(Variant value) => value.AsBoxedObject() ?? 0; + + private static short ToInt16(Variant value) => Convert.ToInt16(BoxOf(value), CultureInfo.InvariantCulture); + + private static ushort ToUInt16(Variant value) => Convert.ToUInt16(BoxOf(value), CultureInfo.InvariantCulture); + + private static string Normalize(string? type) + { + switch ((type ?? "uint16").Trim().ToLowerInvariant()) + { + case "short": + case "int16": + return "int16"; + case "ushort": + case "uint16": + case "word": + return "uint16"; + case "int": + case "int32": + return "int32"; + case "uint": + case "uint32": + case "dword": + return "uint32"; + case "float": + case "float32": + case "single": + return "float32"; + case "long": + case "int64": + return "int64"; + case "ulong": + case "uint64": + return "uint64"; + case "double": + case "float64": + return "float64"; + default: + return "uint16"; + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusException.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusException.cs new file mode 100644 index 0000000000..0db5ce5275 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusException.cs @@ -0,0 +1,64 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// A Modbus protocol exception carrying the device exception code. + public sealed class ModbusException : Exception + { + /// Initializes a new Modbus exception. + public ModbusException(byte exceptionCode, string message) + : base(message) + { + ExceptionCode = exceptionCode; + } + + /// Initializes a new Modbus exception without a device code. + public ModbusException(string message) + : base(message) + { + } + + /// Initializes a new Modbus exception. + public ModbusException() + { + } + + /// Initializes a new Modbus exception with an inner exception. + public ModbusException(string message, Exception innerException) + : base(message, innerException) + { + } + + /// Gets the Modbus exception code, or 0 for a transport fault. + public byte ExceptionCode { get; } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusTcpClient.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusTcpClient.cs new file mode 100644 index 0000000000..f43a297755 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusTcpClient.cs @@ -0,0 +1,378 @@ +/* ======================================================================== + * 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.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// + /// A minimal, robust Modbus TCP client sufficient for the WoT Modbus binding + /// forms: read coils / discrete inputs / holding registers / input registers + /// and write single / multiple coils and holding registers. It manages the + /// MBAP header, monotonically increasing transaction ids, request timeouts and + /// device exception decoding. + /// + public sealed class ModbusTcpClient : IDisposable + { + /// Initializes a new Modbus TCP client. + public ModbusTcpClient(string host, int port, TimeSpan timeout) + { + m_host = host ?? throw new ArgumentNullException(nameof(host)); + m_port = port <= 0 ? 502 : port; + m_timeout = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(10) : timeout; + } + + /// + /// Connects (or reconnects) the underlying TCP socket. The connect is + /// serialised with in-flight transactions so a reconnect after a fault is + /// deterministic and thread-safe: any prior (possibly faulted) socket is + /// disposed first and a fresh connection replaces it atomically. + /// + public async ValueTask ConnectAsync(CancellationToken cancellationToken) + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_timeout); + await m_writeLock.WaitAsync(timeout.Token).ConfigureAwait(false); + try + { + // Dispose any prior (possibly faulted) connection so a reconnect + // always starts from a clean, deterministic state. + m_stream?.Dispose(); + m_client?.Dispose(); + m_stream = null; + m_client = null; + + var client = new TcpClient { NoDelay = true }; + try + { + await client.ConnectAsync(m_host, m_port, timeout.Token).ConfigureAwait(false); + } + catch + { + client.Dispose(); + throw; + } + m_client = client; + m_stream = client.GetStream(); + m_faulted = false; + } + finally + { + m_writeLock.Release(); + } + } + + /// Reads holding registers (function code 3). + public ValueTask ReadHoldingRegistersAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + => ReadRegistersAsync(0x03, unitId, address, quantity, cancellationToken); + + /// Reads input registers (function code 4). + public ValueTask ReadInputRegistersAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + => ReadRegistersAsync(0x04, unitId, address, quantity, cancellationToken); + + /// Reads coils (function code 1). + public ValueTask ReadCoilsAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + => ReadBitsAsync(0x01, unitId, address, quantity, cancellationToken); + + /// Reads discrete inputs (function code 2). + public ValueTask ReadDiscreteInputsAsync( + byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + => ReadBitsAsync(0x02, unitId, address, quantity, cancellationToken); + + /// Writes a single holding register (function code 6). + public async ValueTask WriteSingleRegisterAsync( + byte unitId, ushort address, ushort value, CancellationToken cancellationToken) + { + byte[] pdu = { 0x06, Hi(address), Lo(address), Hi(value), Lo(value) }; + await TransactAsync(unitId, pdu, 0x06, cancellationToken).ConfigureAwait(false); + } + + /// Writes multiple holding registers (function code 16). + public async ValueTask WriteMultipleRegistersAsync( + byte unitId, ushort address, ushort[] values, CancellationToken cancellationToken) + { + int count = values.Length; + byte byteCount = (byte)(count * 2); + byte[] pdu = new byte[6 + byteCount]; + pdu[0] = 0x10; + pdu[1] = Hi(address); + pdu[2] = Lo(address); + pdu[3] = Hi((ushort)count); + pdu[4] = Lo((ushort)count); + pdu[5] = byteCount; + for (int i = 0; i < count; i++) + { + pdu[6 + (i * 2)] = Hi(values[i]); + pdu[7 + (i * 2)] = Lo(values[i]); + } + await TransactAsync(unitId, pdu, 0x10, cancellationToken).ConfigureAwait(false); + } + + /// Writes a single coil (function code 5). + public async ValueTask WriteSingleCoilAsync( + byte unitId, ushort address, bool value, CancellationToken cancellationToken) + { + byte[] pdu = { 0x05, Hi(address), Lo(address), value ? (byte)0xFF : (byte)0x00, 0x00 }; + await TransactAsync(unitId, pdu, 0x05, cancellationToken).ConfigureAwait(false); + } + + /// Writes multiple coils (function code 15). + public async ValueTask WriteMultipleCoilsAsync( + byte unitId, ushort address, bool[] values, CancellationToken cancellationToken) + { + int count = values.Length; + byte byteCount = (byte)((count + 7) / 8); + byte[] pdu = new byte[6 + byteCount]; + pdu[0] = 0x0F; + pdu[1] = Hi(address); + pdu[2] = Lo(address); + pdu[3] = Hi((ushort)count); + pdu[4] = Lo((ushort)count); + pdu[5] = byteCount; + for (int i = 0; i < count; i++) + { + if (values[i]) + { + pdu[6 + (i / 8)] |= (byte)(1 << (i % 8)); + } + } + await TransactAsync(unitId, pdu, 0x0F, cancellationToken).ConfigureAwait(false); + } + + /// + public void Dispose() + { + m_stream?.Dispose(); + m_client?.Dispose(); + m_writeLock.Dispose(); + } + + private async ValueTask ReadRegistersAsync( + byte function, byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + byte[] pdu = { function, Hi(address), Lo(address), Hi(quantity), Lo(quantity) }; + byte[] response = await TransactAsync(unitId, pdu, function, cancellationToken).ConfigureAwait(false); + // response[0] is the (already validated) function code; response[1] is + // the byte count. Validate both the byte count and the overall length + // before indexing so a hostile or truncated frame cannot read out of + // bounds. + if (response.Length < 2) + { + throw new ModbusException("The Modbus register response is missing its byte count."); + } + int byteCount = response[1]; + int expected = quantity * 2; + if (byteCount != expected || (byteCount & 1) != 0 || response.Length < 2 + byteCount) + { + throw new ModbusException( + "The Modbus register response byte count is inconsistent with the request."); + } + var registers = new ushort[byteCount / 2]; + for (int i = 0; i < registers.Length; i++) + { + registers[i] = (ushort)((response[2 + (i * 2)] << 8) | response[3 + (i * 2)]); + } + return registers; + } + + private async ValueTask ReadBitsAsync( + byte function, byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken) + { + byte[] pdu = { function, Hi(address), Lo(address), Hi(quantity), Lo(quantity) }; + byte[] response = await TransactAsync(unitId, pdu, function, cancellationToken).ConfigureAwait(false); + // response[1] is the packed-bit byte count. Validate it against the + // requested quantity and the frame length before indexing. + if (response.Length < 2) + { + throw new ModbusException("The Modbus bit response is missing its byte count."); + } + int byteCount = response[1]; + int expected = (quantity + 7) / 8; + if (byteCount != expected || response.Length < 2 + byteCount) + { + throw new ModbusException( + "The Modbus bit response byte count is inconsistent with the request."); + } + var bits = new bool[quantity]; + for (int i = 0; i < quantity; i++) + { + int byteIndex = 2 + (i / 8); + bits[i] = (response[byteIndex] & (1 << (i % 8))) != 0; + } + return bits; + } + + private async ValueTask TransactAsync( + byte unitId, byte[] pdu, byte expectedFunction, CancellationToken cancellationToken) + { + ushort transactionId = unchecked((ushort)Interlocked.Increment(ref m_transaction)); + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = Hi(transactionId); + frame[1] = Lo(transactionId); + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = Hi((ushort)length); + frame[5] = Lo((ushort)length); + frame[6] = unitId; + Buffer.BlockCopy(pdu, 0, frame, 7, pdu.Length); + + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_timeout); + await m_writeLock.WaitAsync(timeout.Token).ConfigureAwait(false); + try + { + NetworkStream? stream = m_stream; + if (stream is null) + { + throw new ModbusException(m_faulted + ? "The Modbus connection was faulted by a previous error and must be reconnected." + : "The Modbus client is not connected."); + } + + byte[] responsePdu; + try + { + await stream.WriteAsync(frame.AsMemory(), timeout.Token).ConfigureAwait(false); + await stream.FlushAsync(timeout.Token).ConfigureAwait(false); + + byte[] header = await ReadExactAsync(stream, 7, timeout.Token).ConfigureAwait(false); + if (header[0] != Hi(transactionId) || header[1] != Lo(transactionId)) + { + throw new ModbusException("The Modbus transaction id did not match."); + } + int responseLength = ((header[4] << 8) | header[5]) - 1; + if (responseLength < 1) + { + throw new ModbusException("The Modbus response length is invalid."); + } + responsePdu = await ReadExactAsync(stream, responseLength, timeout.Token).ConfigureAwait(false); + } + catch (Exception ex) when ( + ex is OperationCanceledException or System.IO.IOException or + SocketException or ObjectDisposedException or ModbusException) + { + // A timeout, cancellation, transport error, transaction-id + // mismatch or truncated/invalid frame leaves the stream in an + // unknown, desynchronized state. Fault the connection so every + // subsequent operation fails fast until a fresh ConnectAsync + // re-establishes the socket. + FaultConnection(); + throw; + } + + // The response was framed by the MBAP length and read in full, so + // the stream stays synchronized: a device exception or an + // unexpected function code is a protocol result, not a desync, and + // must not fault the connection. + byte function = responsePdu[0]; + if ((function & 0x80) != 0) + { + byte exceptionCode = responsePdu.Length > 1 ? responsePdu[1] : (byte)0; + throw new ModbusException(exceptionCode, DescribeException(exceptionCode)); + } + if (function != expectedFunction) + { + throw new ModbusException( + $"Unexpected Modbus function 0x{function:X2} (expected 0x{expectedFunction:X2})."); + } + return responsePdu; + } + finally + { + m_writeLock.Release(); + } + } + + /// + /// Disposes and clears the current socket after a desynchronizing fault so + /// the next operation requires a fresh . Always + /// called while holding . + /// + private void FaultConnection() + { + m_faulted = true; + m_stream?.Dispose(); + m_client?.Dispose(); + m_stream = null; + m_client = null; + } + + private static async ValueTask ReadExactAsync( + NetworkStream stream, int count, CancellationToken cancellationToken) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream + .ReadAsync(buffer.AsMemory(offset, count - offset), cancellationToken) + .ConfigureAwait(false); + if (read == 0) + { + throw new ModbusException("The Modbus connection was closed by the peer."); + } + offset += read; + } + return buffer; + } + + private static string DescribeException(byte code) + { + return code switch + { + 0x01 => "Illegal function.", + 0x02 => "Illegal data address.", + 0x03 => "Illegal data value.", + 0x04 => "Server device failure.", + 0x06 => "Server device busy.", + _ => $"Modbus exception 0x{code:X2}." + }; + } + + private static byte Hi(ushort value) => (byte)(value >> 8); + + private static byte Lo(ushort value) => (byte)(value & 0xFF); + + private readonly string m_host; + private readonly int m_port; + private readonly TimeSpan m_timeout; + private readonly SemaphoreSlim m_writeLock = new SemaphoreSlim(1, 1); + private TcpClient? m_client; + private NetworkStream? m_stream; + private int m_transaction; + private bool m_faulted; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingChannel.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingChannel.cs new file mode 100644 index 0000000000..2b7301469f --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingChannel.cs @@ -0,0 +1,228 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// + /// A live Modbus TCP binding channel. It reads coils / discrete inputs / + /// holding / input registers and writes coils and holding registers with the + /// data type and byte / word order compiled from the form, mapping Modbus + /// exceptions and timeouts to OPC UA status codes. + /// + internal sealed class ModbusWotBindingChannel : IWotBindingChannel + { + public ModbusWotBindingChannel( + ModbusTcpClient client, + WotCompiledForm form, + WotExecutorContext context, + ModbusWotBindingOptions options, + ModbusAddressing addressing) + { + m_client = client; + m_form = form; + m_context = context; + m_options = options; + + m_entity = addressing.Entity; + m_address = addressing.Address; + m_quantity = addressing.Quantity; + m_unitId = addressing.UnitId; + m_type = addressing.Type; + m_msbFirst = addressing.MsbFirst; + m_mswFirst = addressing.MswFirst; + } + + public WotCompiledForm Form => m_form; + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + try + { + Variant value; + if (IsCoil()) + { + bool[] bits = await m_client + .ReadCoilsAsync(m_unitId, m_address, 1, cancellationToken).ConfigureAwait(false); + value = new Variant(bits.Length > 0 && bits[0]); + } + else if (IsDiscreteInput()) + { + bool[] bits = await m_client + .ReadDiscreteInputsAsync(m_unitId, m_address, 1, cancellationToken).ConfigureAwait(false); + value = new Variant(bits.Length > 0 && bits[0]); + } + else if (IsInputRegister()) + { + ushort[] regs = await m_client + .ReadInputRegistersAsync(m_unitId, m_address, m_quantity, cancellationToken).ConfigureAwait(false); + value = ModbusDataConverter.ToVariant(regs, m_type, m_msbFirst, m_mswFirst); + } + else + { + ushort[] regs = await m_client + .ReadHoldingRegistersAsync(m_unitId, m_address, m_quantity, cancellationToken).ConfigureAwait(false); + value = ModbusDataConverter.ToVariant(regs, m_type, m_msbFirst, m_mswFirst); + } + return new WotReadResult( + StatusCodes.Good, new DataValue(value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + } + catch (ModbusException ex) + { + StatusCode status = ModbusStatusMapper.Map(ex); + return new WotReadResult(status, DataValue.FromStatusCode(status), ex.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotReadResult( + StatusCodes.BadTimeout, DataValue.FromStatusCode(StatusCodes.BadTimeout), "The Modbus request timed out."); + } + catch (System.IO.IOException ex) + { + return new WotReadResult( + StatusCodes.BadCommunicationError, + DataValue.FromStatusCode(StatusCodes.BadCommunicationError), ex.Message); + } + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + if (IsDiscreteInput() || IsInputRegister()) + { + return new WotWriteResult(StatusCodes.BadNotWritable, "The Modbus entity is read-only."); + } + try + { + if (IsCoil()) + { + bool on = Convert.ToBoolean(value.WrappedValue.AsBoxedObject() ?? false, CultureInfo.InvariantCulture); + await m_client.WriteSingleCoilAsync(m_unitId, m_address, on, cancellationToken).ConfigureAwait(false); + } + else + { + ushort[] registers = ModbusDataConverter.ToRegisters(value.WrappedValue, m_type, m_msbFirst, m_mswFirst); + if (registers.Length == 1) + { + await m_client + .WriteSingleRegisterAsync(m_unitId, m_address, registers[0], cancellationToken) + .ConfigureAwait(false); + } + else + { + await m_client + .WriteMultipleRegistersAsync(m_unitId, m_address, registers, cancellationToken) + .ConfigureAwait(false); + } + } + return new WotWriteResult(StatusCodes.Good); + } + catch (ModbusException ex) + { + return new WotWriteResult(ModbusStatusMapper.Map(ex), ex.Message); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotWriteResult(StatusCodes.BadTimeout, "The Modbus request timed out."); + } + catch (System.IO.IOException ex) + { + return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message); + } + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + => new ValueTask(new WotInvokeResult( + StatusCodes.BadNotSupported, null, "Modbus does not support action invocation.")); + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription( + m_form, + async token => + { + WotReadResult result = await ReadAsync(token).ConfigureAwait(false); + if (result.Success) + { + onNotification(new WotNotification(result.Value)); + } + }, + m_options.ObserveInterval, + // A transient poll fault is reported as a Bad-status notification + // so consumers observe the fault without the poll loop faulting. + onError: _ => onNotification(new WotNotification( + DataValue.FromStatusCode(StatusCodes.BadCommunicationError)))); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + => ObserveAsync(onEvent, cancellationToken); + + public ValueTask DisposeAsync() + { + m_client.Dispose(); + return default; + } + + private bool IsCoil() => string.Equals(m_entity, "coil", StringComparison.OrdinalIgnoreCase); + + private bool IsDiscreteInput() + => string.Equals(m_entity, "discreteInput", StringComparison.OrdinalIgnoreCase); + + private bool IsInputRegister() + => string.Equals(m_entity, "inputRegister", StringComparison.OrdinalIgnoreCase); + + private readonly ModbusTcpClient m_client; + private readonly WotCompiledForm m_form; + private readonly WotExecutorContext m_context; + private readonly ModbusWotBindingOptions m_options; + private readonly string m_entity; + private readonly ushort m_address; + private readonly ushort m_quantity; + private readonly byte m_unitId; + private readonly string m_type; + private readonly bool m_msbFirst; + private readonly bool m_mswFirst; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingExecutor.cs new file mode 100644 index 0000000000..245c33dde0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingExecutor.cs @@ -0,0 +1,94 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// + /// Executes Modbus TCP WoT binding forms compiled by the + /// by opening a per-form Modbus TCP + /// connection. + /// + public sealed class ModbusWotBindingExecutor : IWotBindingExecutor + { + /// Initializes a new Modbus executor. + public ModbusWotBindingExecutor(ModbusWotBindingOptions? options = null) + { + m_options = options ?? new ModbusWotBindingOptions(); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri, "W3C WoT Modbus Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + => form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The client is owned by the returned channel, which disposes it.")] + public async ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + // Re-validate the addressing (and perform the ushort / byte casts) before + // opening the socket so a hand-built or tampered compiled form fails fast + // and never leaks a half-open connection. + ModbusAddressing addressing = ModbusAddressing.FromForm(form); + string host = string.IsNullOrEmpty(form.Endpoint.Host) ? "127.0.0.1" : form.Endpoint.Host!; + int port = form.Endpoint.Port > 0 ? form.Endpoint.Port : 502; + var client = new ModbusTcpClient(host, port, context.Bounds.DefaultTimeout); + try + { + await client.ConnectAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + client.Dispose(); + throw; + } + return new ModbusWotBindingChannel(client, form, context, m_options, addressing); + } + + private readonly ModbusWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingOptions.cs b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingOptions.cs new file mode 100644 index 0000000000..7c5bfd8975 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/ModbusWotBindingOptions.cs @@ -0,0 +1,57 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Binding.Modbus +{ + /// Options for the Modbus TCP WoT binding executor. + public sealed class ModbusWotBindingOptions + { + /// Gets or sets the poll interval used for observe operations. + public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1); + } + + /// Maps Modbus device exception codes to OPC UA status codes. + internal static class ModbusStatusMapper + { + public static StatusCode Map(ModbusException exception) + { + return exception.ExceptionCode switch + { + 0x01 => StatusCodes.BadNotSupported, + 0x02 => StatusCodes.BadNodeIdUnknown, + 0x03 => StatusCodes.BadInvalidArgument, + 0x04 => StatusCodes.BadInternalError, + 0x06 => StatusCodes.BadResourceUnavailable, + _ => StatusCodes.BadCommunicationError + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/NugetREADME.md b/src/Opc.Ua.WotCon.Binding.Modbus/NugetREADME.md new file mode 100644 index 0000000000..535523f94a --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/NugetREADME.md @@ -0,0 +1,25 @@ +# OPC UA WoT Connectivity — Modbus TCP Executor + +`OPCFoundation.NetStandard.Opc.Ua.WotCon.Binding.Modbus` executes Modbus TCP +WoT Connectivity binding forms compiled by the Modbus planner in +`Opc.Ua.WotCon.Binding`. + +It provides a minimal, robust Modbus TCP client and executor supporting coils, +discrete inputs, holding and input registers, the required read / write +function codes, unit id / address / quantity addressing, byte / word order and +data-type conversion, with strict bounds, transaction ids and timeouts. + +## Addressing and function validation + +- The planner enforces a 16-bit address space: `modv:address` must be + 0–65535 and the addressed range (`address + quantity - 1`) must stay + within it. +- Function-only forms map exactly onto the Modbus function codes 1, 2, 3, 4, 5, + 6, 15 and 16 (string mnemonics or numeric codes). A `modv:function` that + conflicts with `modv:entity`, or whose direction conflicts with the operation + (a write function on a read op or vice versa), is rejected. +- The executor re-validates the address / quantity range before narrowing the + values to `ushort` / `byte`, so a hand-built or tampered compiled form fails + fast instead of silently truncating. + +Register it with `builder.AddModbusWotBinding()`. diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/Opc.Ua.WotCon.Binding.Modbus.csproj b/src/Opc.Ua.WotCon.Binding.Modbus/Opc.Ua.WotCon.Binding.Modbus.csproj new file mode 100644 index 0000000000..a1cba80a56 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/Opc.Ua.WotCon.Binding.Modbus.csproj @@ -0,0 +1,28 @@ + + + $(AssemblyPrefix).WotCon.Binding.Modbus + net8.0;net9.0;net10.0 + $(CustomTestTarget) + $(PackagePrefix).Opc.Ua.WotCon.Binding.Modbus + Opc.Ua.WotCon.Binding.Modbus + $(NoWarn);CS1591 + enable + Modbus TCP protocol executor for OPC UA WoT Connectivity binding forms + true + NugetREADME.md + true + true + + + + + + $(PackageId).Debug + + + + + + + + diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/OpcUaModbusWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Binding.Modbus/OpcUaModbusWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..8914ddee41 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/OpcUaModbusWotBindingBuilderExtensions.cs @@ -0,0 +1,60 @@ +/* ======================================================================== + * 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 Opc.Ua; +using Opc.Ua.WotCon.Binding.Modbus; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the Modbus TCP WoT + /// binding executor alongside the shipped planner binders. + /// + public static class OpcUaModbusWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the Modbus TCP executor, + /// so Modbus binding forms are validated, compiled and executable. + /// + public static IOpcUaBuilder AddModbusWotBinding( + this IOpcUaBuilder builder, Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + var options = new ModbusWotBindingOptions(); + configure?.Invoke(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new ModbusWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Modbus/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Binding.Modbus/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Modbus/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingChannel.cs b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingChannel.cs new file mode 100644 index 0000000000..3ec309b7e5 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingChannel.cs @@ -0,0 +1,318 @@ +/* ======================================================================== + * 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.Buffers; +using System.Collections.Generic; +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet; +using MQTTnet.Exceptions; +using MQTTnet.Protocol; + +namespace Opc.Ua.WotCon.Binding.Mqtt +{ + /// + /// A live MQTT binding channel that publishes writes / actions and subscribes + /// for reads / observes / events per the pinned MQTT binding, with bounded QoS, + /// payload sizes and read timeouts. + /// + internal sealed class MqttWotBindingChannel : IWotBindingChannel + { + public MqttWotBindingChannel( + IMqttClient client, + WotCompiledForm form, + WotExecutorContext context, + MqttWotBindingOptions options) + { + m_client = client; + m_form = form; + m_context = context; + m_options = options; + m_topic = form.Addressing.Target; + m_qos = ParseQos(form.Addressing.Metadata); + m_retain = ParseBool(form.Addressing.Metadata, "retain"); + context.Codecs.TrySelect(form.Payload.ContentType, out m_codec); + m_client.ApplicationMessageReceivedAsync += OnMessageAsync; + } + + public WotCompiledForm Form => m_form; + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Interlocked.Exchange(ref m_pendingRead, completion); + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(m_options.ReadTimeout); + try + { + await SubscribeAsync(cancellationToken).ConfigureAwait(false); + byte[] payload = await completion.Task.WaitAsync(timeout.Token).ConfigureAwait(false); + WotDecodeResult decoded = m_codec.Decode(payload, m_form.Payload); + if (!decoded.Success) + { + return new WotReadResult( + StatusCodes.BadDecodingError, DataValue.FromStatusCode(StatusCodes.BadDecodingError), decoded.Error); + } + return new WotReadResult( + StatusCodes.Good, new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotReadResult( + StatusCodes.BadTimeout, DataValue.FromStatusCode(StatusCodes.BadTimeout), + "Timed out waiting for an MQTT message."); + } + finally + { + Interlocked.CompareExchange(ref m_pendingRead, null, completion); + if (!m_observing) + { + await TryUnsubscribeAsync().ConfigureAwait(false); + } + } + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + WotEncodeResult encoded = m_codec.Encode(value.WrappedValue, m_form.Payload); + if (!encoded.Success) + { + return new WotWriteResult(StatusCodes.BadEncodingError, encoded.Error); + } + try + { + await PublishAsync(encoded.Data.ToArray(), cancellationToken).ConfigureAwait(false); + return new WotWriteResult(StatusCodes.Good); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + return new WotWriteResult(StatusCodes.BadTimeout, "The MQTT publish timed out."); + } + catch (MqttCommunicationException ex) + { + return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message); + } + } + + public async ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + byte[] payload = Array.Empty(); + if (inputs is { Count: > 0 }) + { + WotEncodeResult encoded = m_codec.Encode(inputs[0], m_form.Payload); + if (!encoded.Success) + { + return new WotInvokeResult(StatusCodes.BadEncodingError, null, encoded.Error); + } + payload = encoded.Data.ToArray(); + } + try + { + await PublishAsync(payload, cancellationToken).ConfigureAwait(false); + return new WotInvokeResult(StatusCodes.Good, Array.Empty()); + } + catch (MqttCommunicationException ex) + { + return new WotInvokeResult(StatusCodes.BadCommunicationError, null, ex.Message); + } + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public async ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + lock (m_lock) + { + m_handlers.Add(onNotification); + m_observing = true; + } + await SubscribeAsync(cancellationToken).ConfigureAwait(false); + return new HandlerSubscription(this, onNotification); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + => ObserveAsync(onEvent, cancellationToken); + + public async ValueTask DisposeAsync() + { + m_client.ApplicationMessageReceivedAsync -= OnMessageAsync; + try + { + await m_client.DisconnectAsync(new MqttClientDisconnectOptionsBuilder().Build()) + .ConfigureAwait(false); + } + catch (MqttCommunicationException) + { + // Ignore disconnect faults during teardown. + } + m_client.Dispose(); + } + + private Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args) + { + byte[] payload = ToArray(args.ApplicationMessage.Payload); + TaskCompletionSource? pending = Interlocked.Exchange(ref m_pendingRead, null); + pending?.TrySetResult(payload); + + Action[] handlers; + lock (m_lock) + { + if (m_handlers.Count == 0) + { + return Task.CompletedTask; + } + handlers = m_handlers.ToArray(); + } + WotDecodeResult decoded = m_codec.Decode(payload, m_form.Payload); + if (decoded.Success) + { + var notification = new WotNotification( + new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now)); + foreach (Action handler in handlers) + { + handler(notification); + } + } + return Task.CompletedTask; + } + + private async Task SubscribeAsync(CancellationToken cancellationToken) + { + MqttClientSubscribeOptions options = new MqttClientSubscribeOptionsBuilder() + .WithTopicFilter(m_topic, m_qos) + .Build(); + await m_client.SubscribeAsync(options, cancellationToken).ConfigureAwait(false); + } + + private async Task TryUnsubscribeAsync() + { + try + { + MqttClientUnsubscribeOptions options = new MqttClientUnsubscribeOptionsBuilder() + .WithTopicFilter(m_topic) + .Build(); + await m_client.UnsubscribeAsync(options).ConfigureAwait(false); + } + catch (MqttCommunicationException) + { + // Ignore unsubscribe faults. + } + } + + private async Task PublishAsync(byte[] payload, CancellationToken cancellationToken) + { + MqttApplicationMessage message = new MqttApplicationMessageBuilder() + .WithTopic(m_topic) + .WithPayload(payload) + .WithQualityOfServiceLevel(m_qos) + .WithRetainFlag(m_retain) + .Build(); + await m_client.PublishAsync(message, cancellationToken).ConfigureAwait(false); + } + + private void RemoveHandler(Action handler) + { + lock (m_lock) + { + m_handlers.Remove(handler); + m_observing = m_handlers.Count > 0; + } + } + + private static byte[] ToArray(ReadOnlySequence payload) + => payload.IsEmpty ? Array.Empty() : payload.ToArray(); + + private static MqttQualityOfServiceLevel ParseQos( + System.Collections.Immutable.ImmutableDictionary metadata) + { + if (metadata.TryGetValue("qos", out string? value) && + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int qos)) + { + return qos switch + { + 1 => MqttQualityOfServiceLevel.AtLeastOnce, + 2 => MqttQualityOfServiceLevel.ExactlyOnce, + _ => MqttQualityOfServiceLevel.AtMostOnce + }; + } + return MqttQualityOfServiceLevel.AtMostOnce; + } + + private static bool ParseBool( + System.Collections.Immutable.ImmutableDictionary metadata, string key) + => metadata.TryGetValue(key, out string? value) && bool.TryParse(value, out bool result) && result; + + private sealed class HandlerSubscription : IWotSubscription + { + public HandlerSubscription(MqttWotBindingChannel channel, Action handler) + { + m_channel = channel; + m_handler = handler; + } + + public WotCompiledForm Form => m_channel.m_form; + + public async ValueTask DisposeAsync() + { + m_channel.RemoveHandler(m_handler); + if (!m_channel.m_observing) + { + await m_channel.TryUnsubscribeAsync().ConfigureAwait(false); + } + } + + private readonly MqttWotBindingChannel m_channel; + private readonly Action m_handler; + } + + private readonly IMqttClient m_client; + private readonly WotCompiledForm m_form; + private readonly WotExecutorContext m_context; + private readonly MqttWotBindingOptions m_options; + private readonly string m_topic; + private readonly MqttQualityOfServiceLevel m_qos; + private readonly bool m_retain; + private readonly IWotPayloadCodec m_codec; + private readonly object m_lock = new object(); + private readonly List> m_handlers = new List>(); + private volatile bool m_observing; + private TaskCompletionSource? m_pendingRead; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingExecutor.cs new file mode 100644 index 0000000000..0c8ac7bf40 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingExecutor.cs @@ -0,0 +1,97 @@ +/* ======================================================================== + * 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.Globalization; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Binding.Mqtt +{ + /// + /// Executes MQTT WoT binding forms compiled by the + /// by opening a per-form MQTT connection using + /// the repository's MQTTnet infrastructure. + /// + public sealed class MqttWotBindingExecutor : IWotBindingExecutor + { + /// Initializes a new MQTT executor. + public MqttWotBindingExecutor(MqttWotBindingOptions? options = null) + { + m_options = options ?? new MqttWotBindingOptions(); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.mqtt", "1.0-ed", MqttBindingPlanner.BindingUri, "W3C WoT MQTT Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + => form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The client is owned by the returned channel, which disposes it.")] + public async ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + string suffix = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture); + string clientId = string.Concat(m_options.ClientIdPrefix, "-", suffix.AsSpan(0, 12)); + // Resolve credentials / trust and build the options first, so a + // fail-closed rejection throws before any client is created. + MqttWotConnection.MqttWotConnectPlan plan = await MqttWotConnection + .PrepareAsync(form, context, m_options, clientId, cancellationToken).ConfigureAwait(false); + IMqttClient client = m_options.ClientFactory?.Invoke() as IMqttClient + ?? new MqttClientFactory().CreateMqttClient(); + try + { + await client.ConnectAsync(plan.Options, cancellationToken).ConfigureAwait(false); + } + catch + { + client.Dispose(); + throw; + } + return new MqttWotBindingChannel(client, form, context, m_options); + } + + private readonly MqttWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingOptions.cs b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingOptions.cs new file mode 100644 index 0000000000..69a3aeb5b1 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotBindingOptions.cs @@ -0,0 +1,70 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Binding.Mqtt +{ + /// Options for the MQTT WoT binding executor. + public sealed class MqttWotBindingOptions + { + /// + /// Gets or sets a factory that supplies an unconnected MQTT client. When + /// null the executor creates one from the MQTTnet client factory. + /// The return type is to keep the MQTTnet dependency + /// out of callers that only configure the executor; it must be an + /// MQTTnet.IMqttClient. + /// + public Func? ClientFactory { get; set; } + + /// Gets or sets the client id prefix used for connections. + public string ClientIdPrefix { get; set; } = "opcua-wot"; + + /// Gets or sets the timeout awaiting a message during a read. + public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// Gets or sets whether username / password credentials may be sent over a + /// plaintext mqtt:// connection. When false (the default) the + /// executor fails closed rather than leaking credentials in clear text; use + /// an mqtts:// href instead. Set to true only for explicitly + /// accepted plaintext deployments. + /// + public bool AllowCredentialsOverPlaintext { get; set; } + + /// + /// Gets or sets whether the broker's TLS certificate is validated for an + /// mqtts:// connection. When true (the default) the platform + /// trust store, or the trust anchors resolved through the credential + /// provider, must validate the broker certificate. Set to false only + /// for explicitly accepted test deployments. + /// + public bool ValidateServerCertificate { get; set; } = true; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotConnection.cs b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotConnection.cs new file mode 100644 index 0000000000..e0525ff9fb --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/MqttWotConnection.cs @@ -0,0 +1,191 @@ +/* ======================================================================== + * 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.Immutable; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using MQTTnet; + +namespace Opc.Ua.WotCon.Binding.Mqtt +{ + /// + /// Builds the transport-security-aware MQTT client options for a compiled WoT + /// form. The mqtts scheme always enables TLS (defaulting to port 8883) + /// and applies the trust anchors and client certificate resolved through the + /// credential provider; the mqtt scheme stays explicit plaintext + /// (port 1883). The builder fails closed: a declared security scheme that + /// resolves to no credential, or username / password material that would be + /// sent over a plaintext connection, throws instead of downgrading silently. + /// + internal static class MqttWotConnection + { + internal const int DefaultTlsPort = 8883; + internal const int DefaultPlaintextPort = 1883; + + /// The result of preparing an MQTT connection for a compiled form. + internal sealed class MqttWotConnectPlan + { + public MqttWotConnectPlan(MqttClientOptions options, string host, int port, bool useTls, bool hasCredentials) + { + Options = options; + Host = host; + Port = port; + UseTls = useTls; + HasCredentials = hasCredentials; + } + + /// Gets the built MQTT client options. + public MqttClientOptions Options { get; } + + /// Gets the resolved broker host. + public string Host { get; } + + /// Gets the resolved broker port. + public int Port { get; } + + /// Gets whether TLS is enabled for the connection. + public bool UseTls { get; } + + /// Gets whether username / password credentials were applied. + public bool HasCredentials { get; } + } + + /// + /// Resolves credentials / trust through the provider and builds the MQTT + /// client options for the supplied compiled form, enforcing the transport + /// security rules described on the type. + /// + public static async ValueTask PrepareAsync( + WotCompiledForm form, + WotExecutorContext context, + MqttWotBindingOptions options, + string clientId, + CancellationToken cancellationToken) + { + bool useTls = string.Equals(form.Endpoint.Scheme, "mqtts", StringComparison.OrdinalIgnoreCase); + string host = string.IsNullOrEmpty(form.Endpoint.Host) ? "127.0.0.1" : form.Endpoint.Host!; + int port = form.Endpoint.Port > 0 + ? form.Endpoint.Port + : (useTls ? DefaultTlsPort : DefaultPlaintextPort); + + WotCredential? credential = await ResolveRequiredCredentialAsync(form, context, cancellationToken) + .ConfigureAwait(false); + + var builder = new MqttClientOptionsBuilder() + .WithTcpServer(host, port) + .WithClientId(clientId); + + string? username = null; + byte[] password = Array.Empty(); + if (credential is not null) + { + if (credential.Properties.TryGetValue("username", out string? user)) + { + username = user; + } + if (credential.Properties.TryGetValue("password", out string? pass) && pass is not null) + { + password = Encoding.UTF8.GetBytes(pass); + } + } + + bool hasCredentials = !string.IsNullOrEmpty(username); + if (hasCredentials && !useTls && !options.AllowCredentialsOverPlaintext) + { + throw new InvalidOperationException( + "MQTT username / password credentials require TLS. Use an mqtts:// href, or set " + + "MqttWotBindingOptions.AllowCredentialsOverPlaintext for explicitly accepted plaintext deployments."); + } + if (hasCredentials) + { + builder = builder.WithCredentials(username, password); + } + + if (useTls) + { + X509Certificate2? clientCertificate = credential?.ClientCertificate; + ImmutableArray trust = credential is null + ? ImmutableArray.Empty + : credential.TrustedCertificates; + bool validate = options.ValidateServerCertificate; + builder = builder.WithTlsOptions(tls => + { + tls.UseTls().WithAllowUntrustedCertificates(!validate); + if (clientCertificate is not null) + { + tls.WithClientCertificates(new X509Certificate2Collection { clientCertificate }); + } + if (!trust.IsDefaultOrEmpty) + { + var chain = new X509Certificate2Collection(); + foreach (X509Certificate2 anchor in trust) + { + chain.Add(anchor); + } + tls.WithTrustChain(chain); + } + }); + } + + return new MqttWotConnectPlan(builder.Build(), host, port, useTls, hasCredentials); + } + + private static async ValueTask ResolveRequiredCredentialAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken) + { + if (form.Security.IsDefaultOrEmpty) + { + return null; + } + foreach (WotCredentialReference reference in form.Security) + { + if (reference.Scheme == WotSecurityScheme.NoSecurity) + { + continue; + } + WotCredential? credential = await context.Credentials + .ResolveAsync(reference, cancellationToken).ConfigureAwait(false); + if (credential is null) + { + // Fail closed: a form that declares a security scheme must have + // its credential resolved or the connection is refused rather + // than silently opened without the required authentication. + throw new InvalidOperationException( + $"The MQTT binding requires a credential for security scheme '{reference.SchemeName}' " + + "but the credential provider resolved none; refusing to connect."); + } + return credential; + } + return null; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/NugetREADME.md b/src/Opc.Ua.WotCon.Binding.Mqtt/NugetREADME.md new file mode 100644 index 0000000000..9defa83006 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/NugetREADME.md @@ -0,0 +1,26 @@ +# OPC UA WoT Connectivity — MQTT Executor + +`OPCFoundation.NetStandard.Opc.Ua.WotCon.Binding.Mqtt` executes MQTT WoT +Connectivity binding forms compiled by the MQTT planner in +`Opc.Ua.WotCon.Binding`. + +It uses the repository's MQTTnet infrastructure (kept out of the core model +assembly) to implement publish / subscribe / RPC patterns per the pinned MQTT +binding, with bounded QoS, topic, payload sizes and timeouts. The MQTT client +factory is injectable. + +## Transport security + +- An `mqtts://` href always enables TLS and defaults to port 8883; an `mqtt://` + href stays explicit plaintext (port 1883). There is no silent plaintext + downgrade. +- Username / password credentials, the TLS client certificate and the TLS trust + anchors are resolved through the registered `IWotCredentialProvider`. A form + that declares a security scheme fails closed (the connection is refused) when + the provider resolves no credential. +- Username / password credentials are refused over a plaintext `mqtt://` + connection unless `MqttWotBindingOptions.AllowCredentialsOverPlaintext` is set + for an explicitly accepted plaintext deployment. `ValidateServerCertificate` + controls broker-certificate validation for `mqtts://`. + +Register it with `builder.AddMqttWotBinding(...)`. diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/Opc.Ua.WotCon.Binding.Mqtt.csproj b/src/Opc.Ua.WotCon.Binding.Mqtt/Opc.Ua.WotCon.Binding.Mqtt.csproj new file mode 100644 index 0000000000..0476fd97b7 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/Opc.Ua.WotCon.Binding.Mqtt.csproj @@ -0,0 +1,32 @@ + + + $(AssemblyPrefix).WotCon.Binding.Mqtt + net8.0;net9.0;net10.0 + $(CustomTestTarget) + $(PackagePrefix).Opc.Ua.WotCon.Binding.Mqtt + Opc.Ua.WotCon.Binding.Mqtt + $(NoWarn);CS1591 + enable + MQTT protocol executor for OPC UA WoT Connectivity binding forms + true + NugetREADME.md + true + true + + + + + + + $(PackageId).Debug + + + + + + + + + + + diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Binding.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..c76bd62298 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs @@ -0,0 +1,60 @@ +/* ======================================================================== + * 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 Opc.Ua; +using Opc.Ua.WotCon.Binding.Mqtt; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the MQTT WoT binding + /// executor alongside the shipped planner binders. + /// + public static class OpcUaMqttWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the MQTT executor, so + /// MQTT binding forms are validated, compiled and executable. + /// + public static IOpcUaBuilder AddMqttWotBinding( + this IOpcUaBuilder builder, Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + var options = new MqttWotBindingOptions(); + configure?.Invoke(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new MqttWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.Mqtt/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Binding.Mqtt/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.Mqtt/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/NugetREADME.md b/src/Opc.Ua.WotCon.Binding.OpcUa/NugetREADME.md new file mode 100644 index 0000000000..99e3e27889 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/NugetREADME.md @@ -0,0 +1,14 @@ +# OPC UA WoT Connectivity — OPC UA Executor + +`OPCFoundation.NetStandard.Opc.Ua.WotCon.Binding.OpcUa` executes OPC UA WoT +Connectivity binding forms (OPC 10101) compiled by the OPC UA planner in +`Opc.Ua.WotCon.Binding`, enabling OPC UA-to-OPC UA translation. + +It uses the `Opc.Ua.Client` session abstractions to implement read / write / +observe / invoke / subscribe-event against portable `uav:id` NodeIds (including +the portable `nsu=` namespace-URI form), preserving Method argument order and +`StatusCode` / `DataValue` metadata. Observe and event subscription are native +`Subscription` / `MonitoredItem` pairs, not polling. Sessions are supplied +through an injectable session factory. + +Register it with `builder.AddOpcUaWotBinding(...)`. diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/Opc.Ua.WotCon.Binding.OpcUa.csproj b/src/Opc.Ua.WotCon.Binding.OpcUa/Opc.Ua.WotCon.Binding.OpcUa.csproj new file mode 100644 index 0000000000..cd9ef3b4c4 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/Opc.Ua.WotCon.Binding.OpcUa.csproj @@ -0,0 +1,29 @@ + + + $(AssemblyPrefix).WotCon.Binding.OpcUa + net8.0;net9.0;net10.0 + $(CustomTestTarget) + $(PackagePrefix).Opc.Ua.WotCon.Binding.OpcUa + Opc.Ua.WotCon.Binding.OpcUa + $(NoWarn);CS1591 + enable + OPC UA protocol executor for OPC UA WoT Connectivity binding forms (OPC UA-to-OPC UA translation) + true + NugetREADME.md + true + true + + + + + + $(PackageId).Debug + + + + + + + + + diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..9c65265cb7 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs @@ -0,0 +1,64 @@ +/* ======================================================================== + * 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 Opc.Ua; +using Opc.Ua.WotCon.Binding.OpcUa; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register the OPC UA WoT binding + /// executor (OPC UA-to-OPC UA translation) alongside the shipped planner binders. + /// + public static class OpcUaTargetWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner binders and the OPC UA executor, so + /// OPC UA binding forms are validated, compiled and executable. + /// + public static IOpcUaBuilder AddOpcUaWotBinding( + this IOpcUaBuilder builder, Action configure) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configure is null) + { + throw new ArgumentNullException(nameof(configure)); + } + var options = new OpcUaWotBindingOptions(); + configure(options); + return builder + .AddWotProtocolBinders() + .AddWotBindingExecutor(new OpcUaWotBindingExecutor(options)); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingChannel.cs b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingChannel.cs new file mode 100644 index 0000000000..26c612cb58 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingChannel.cs @@ -0,0 +1,539 @@ +/* ======================================================================== + * 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using WellKnownObjectTypeIds = Opc.Ua.Types.ObjectTypeIds; + +namespace Opc.Ua.WotCon.Binding.OpcUa +{ + /// + /// A live OPC UA binding channel that translates WoT operations onto OPC UA + /// services: read / write of a NodeId Value attribute, observe and event + /// subscription via a native / + /// pair (Part 4 §5.12 / §5.13), and action invocation via Method Call + /// preserving argument order and / + /// metadata. + /// + internal sealed class OpcUaWotBindingChannel : IWotBindingChannel + { + public OpcUaWotBindingChannel( + ISession session, + bool disposeSession, + WotCompiledForm form, + WotExecutorContext context, + OpcUaWotBindingOptions options) + { + m_session = session; + m_disposeSession = disposeSession; + m_form = form; + m_context = context; + m_options = options; + m_nodeId = form.Addressing.Target; + } + + public WotCompiledForm Form => m_form; + + public async ValueTask ReadAsync(CancellationToken cancellationToken = default) + { + if (!TryResolveNodeId(m_nodeId, out NodeId nodeId)) + { + return new WotReadResult( + StatusCodes.BadNodeIdInvalid, + DataValue.FromStatusCode(StatusCodes.BadNodeIdInvalid), + $"'{m_nodeId}' is not a valid NodeId."); + } + try + { + DataValue value = await m_session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false); + return new WotReadResult(value.StatusCode, value); + } + catch (ServiceResultException ex) + { + StatusCode status = ex.StatusCode; + return new WotReadResult(status, DataValue.FromStatusCode(status), ex.Message); + } + } + + public async ValueTask WriteAsync( + DataValue value, CancellationToken cancellationToken = default) + { + if (!TryResolveNodeId(m_nodeId, out NodeId nodeId)) + { + return new WotWriteResult(StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid NodeId."); + } + try + { + var write = new WriteValue + { + NodeId = nodeId, + AttributeId = Attributes.Value, + Value = new DataValue(value.WrappedValue) + }; + WriteResponse response = await m_session + .WriteAsync(null, new WriteValue[] { write }, cancellationToken).ConfigureAwait(false); + StatusCode status = response.Results is { Count: > 0 } + ? response.Results[0] : StatusCodes.BadUnexpectedError; + return new WotWriteResult(status, StatusCode.IsBad(status) ? status.ToString() : null); + } + catch (ServiceResultException ex) + { + return new WotWriteResult(ex.StatusCode, ex.Message); + } + } + + public async ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + { + if (!m_form.Addressing.Metadata.TryGetValue("componentOf", out string? objectRef) || + string.IsNullOrEmpty(objectRef) || !TryResolveNodeId(objectRef!, out NodeId objectId)) + { + return new WotInvokeResult( + StatusCodes.BadNodeIdInvalid, null, + "An OPC UA action requires a uav:componentOf object NodeId."); + } + if (!TryResolveNodeId(m_nodeId, out NodeId methodId)) + { + return new WotInvokeResult( + StatusCodes.BadNodeIdInvalid, null, $"'{m_nodeId}' is not a valid method NodeId."); + } + try + { + Variant[] arguments = inputs is null ? Array.Empty() : inputs.ToArray(); + ArrayOf outputs = await m_session + .CallAsync(objectId, methodId, cancellationToken, arguments).ConfigureAwait(false); + var results = new DataValue[outputs.Count]; + for (int i = 0; i < outputs.Count; i++) + { + results[i] = new DataValue(outputs[i], StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now); + } + return new WotInvokeResult(StatusCodes.Good, results); + } + catch (ServiceResultException ex) + { + return new WotInvokeResult(ex.StatusCode, null, ex.Message); + } + } + + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + if (!TryResolveNodeId(m_nodeId, out NodeId nodeId)) + { + throw new ServiceResultException( + StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid NodeId."); + } + // Native data-change subscription: the server samples and reports + // changes (Part 4 §5.12); no client-side polling is involved. + return CreateMonitoredSubscriptionAsync( + nodeId, + NodeClass.Variable, + Attributes.Value, + filter: null, + queueSize: 1, + translate: static (_, notificationValue) => notificationValue is MonitoredItemNotification change + ? new WotNotification(change.Value) + : null, + onNotification, + cancellationToken); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + { + if (onEvent is null) + { + throw new ArgumentNullException(nameof(onEvent)); + } + if (!TryResolveNodeId(m_nodeId, out NodeId notifierId)) + { + throw new ServiceResultException( + StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid event notifier NodeId."); + } + EventFilter filter = BuildEventFilter(); + return CreateMonitoredSubscriptionAsync( + notifierId, + NodeClass.Object, + Attributes.EventNotifier, + filter, + queueSize: m_options.EventQueueSize, + translate: (_, notificationValue) => notificationValue is EventFieldList eventFields + ? BuildEventNotification(filter, eventFields) + : null, + onEvent, + cancellationToken); + } + + public ValueTask DisposeAsync() + { + if (m_disposeSession) + { + m_session.Dispose(); + } + return default; + } + + /// + /// Opens a native OPC UA with a single + /// , translates each notification through + /// and forwards it to . + /// Ownership of the created subscription (and its server-side + /// resources) transfers to the returned ; + /// on any failure to create/apply, the subscription is torn down and + /// removed from the session before the exception propagates, so no + /// session/subscription is leaked. + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller (an " + + "IWotSubscription), who disposes it; on failure it is disposed in the catch block.")] + private async ValueTask CreateMonitoredSubscriptionAsync( + NodeId targetId, + NodeClass nodeClass, + uint attributeId, + MonitoringFilter? filter, + uint queueSize, + Func translate, + Action onNotification, + CancellationToken cancellationToken) + { + int interval = NormalizeInterval(m_options.ObserveInterval); + var subscription = new Subscription(m_session.DefaultSubscription) + { + DisplayName = "wot-" + m_form.AffordanceName, + PublishingEnabled = true, + PublishingInterval = interval + }; + m_session.AddSubscription(subscription); + try + { + await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); + + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = targetId, + NodeClass = nodeClass, + AttributeId = attributeId, + DisplayName = m_form.AffordanceName, + SamplingInterval = interval, + QueueSize = queueSize, + DiscardOldest = true + }; + if (filter is not null) + { + item.Filter = filter; + } + + void OnItemNotification(MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e) + { + WotNotification? notification = translate(monitoredItem, e.NotificationValue); + if (notification is not null) + { + onNotification(notification); + } + } + item.Notification += OnItemNotification; + + subscription.AddItem(item); + await subscription.ApplyChangesAsync(cancellationToken).ConfigureAwait(false); + + if (!item.Created) + { + item.Notification -= OnItemNotification; + StatusCode status = item.Status.Error?.StatusCode ?? StatusCodes.BadMonitoredItemFilterUnsupported; + throw new ServiceResultException( + status, + item.Status.Error?.ToString() ?? "The server rejected the monitored item."); + } + + return new OpcUaMonitoredItemSubscription(m_form, m_session, subscription, item, OnItemNotification); + } + catch + { + await RemoveSubscriptionSafeAsync(subscription).ConfigureAwait(false); + throw; + } + } + + private async ValueTask RemoveSubscriptionSafeAsync(Subscription subscription) + { + try + { + await m_session.RemoveSubscriptionAsync(subscription, CancellationToken.None).ConfigureAwait(false); + } + catch (ServiceResultException) + { + // Best-effort server-side cleanup; the session or subscription + // may already be unusable (for example a closed session). + } + subscription.Dispose(); + } + + /// + /// Builds the event select filter: , + /// , , + /// , , + /// , + /// and , plus any binding-authored + /// uav:eventFields select clauses carried by the compiled form. + /// + private EventFilter BuildEventFilter() + { + var filter = new EventFilter(); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.EventId)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.EventType)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.SourceNode)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.SourceName)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Time)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.ReceiveTime)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Message)); + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Severity)); + + if (m_form.Addressing.Metadata.TryGetValue("eventFields", out string? extra) && + !string.IsNullOrEmpty(extra)) + { + var seen = new HashSet(StringComparer.Ordinal) + { + EventBrowseNames.EventId, EventBrowseNames.EventType, EventBrowseNames.SourceNode, + EventBrowseNames.SourceName, EventBrowseNames.Time, EventBrowseNames.ReceiveTime, + EventBrowseNames.Message, EventBrowseNames.Severity + }; + foreach (string field in extra.Split('|', StringSplitOptions.RemoveEmptyEntries)) + { + if (seen.Add(field)) + { + filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, field, Attributes.Value); + } + } + } + return filter; + } + + /// + /// Projects a raw notification into a + /// deterministically: every select-clause + /// field is captured in keyed + /// by its browse path, each carrying the event's own Time / ReceiveTime + /// as its source / server timestamp so no timestamp is lost. The + /// notification's primary wraps the Message + /// field (or the first field, if Message was not selected) with a + /// status. + /// + private static WotNotification BuildEventNotification(EventFilter filter, EventFieldList eventFields) + { + ArrayOf values = eventFields.EventFields; + int count = Math.Min(filter.SelectClauses.Count, values.Count); + + DateTimeUtc sourceTimestamp = DateTimeUtc.Now; + DateTimeUtc serverTimestamp = DateTimeUtc.Now; + for (int i = 0; i < count; i++) + { + string name = FormatFieldName(filter.SelectClauses[i]); + if (string.Equals(name, EventBrowseNames.Time, StringComparison.Ordinal) && + values[i].TryGetValue(out DateTimeUtc time)) + { + sourceTimestamp = time; + } + else if (string.Equals(name, EventBrowseNames.ReceiveTime, StringComparison.Ordinal) && + values[i].TryGetValue(out DateTimeUtc receiveTime)) + { + serverTimestamp = receiveTime; + } + } + + var fields = new Dictionary(count, StringComparer.Ordinal); + Variant primary = Variant.Null; + bool havePrimary = false; + for (int i = 0; i < count; i++) + { + string name = FormatFieldName(filter.SelectClauses[i]); + Variant fieldValue = values[i]; + fields[name] = new DataValue(fieldValue, StatusCodes.Good, sourceTimestamp, serverTimestamp); + if (string.Equals(name, EventBrowseNames.Message, StringComparison.Ordinal)) + { + primary = fieldValue; + havePrimary = true; + } + } + if (!havePrimary && count > 0) + { + primary = values[0]; + } + + var dataValue = new DataValue(primary, StatusCodes.Good, sourceTimestamp, serverTimestamp); + return new WotNotification(dataValue, fields); + } + + /// Formats a select-clause browse path without its leading separator. + private static string FormatFieldName(SimpleAttributeOperand clause) + { + string formatted = SimpleAttributeOperand.Format(clause.BrowsePath); + return formatted.Length > 0 && formatted[0] == '/' ? formatted[1..] : formatted; + } + + /// + /// The mandatory BaseEventType browse names (Part 5 §6.4.2) used to + /// build the baseline event select filter. These are stable OPC UA + /// browse names, so they are declared locally rather than depending on + /// a per-project generated identifier set. + /// + private static class EventBrowseNames + { + public const string EventId = "EventId"; + public const string EventType = "EventType"; + public const string SourceNode = "SourceNode"; + public const string SourceName = "SourceName"; + public const string Time = "Time"; + public const string ReceiveTime = "ReceiveTime"; + public const string Message = "Message"; + public const string Severity = "Severity"; + } + + /// Normalizes an observe interval into a bounded millisecond publishing/sampling interval. + private static int NormalizeInterval(TimeSpan interval) + { + double ms = interval.TotalMilliseconds; + return ms > 100.0 ? (int)ms : 100; + } + + /// + /// Resolves a compiled-form NodeId string to a local . + /// Plain ns= / i= / s= / g= / b= forms + /// resolve without a session round-trip. A portable NodeId carrying an + /// nsu= namespace URI (Part 6 §5.3.1.11) cannot be resolved by + /// alone (it always fails for that + /// form), so it is parsed as an and + /// resolved against the connected session's namespace table. + /// + private bool TryResolveNodeId(string value, out NodeId nodeId) + { + if (TryParseNodeId(value, out nodeId)) + { + return true; + } + if (!ExpandedNodeId.TryParse(value, out ExpandedNodeId expanded) || expanded.IsNull) + { + nodeId = NodeId.Null; + return false; + } + nodeId = ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + return !nodeId.IsNull; + } + + private static bool TryParseNodeId(string value, out NodeId nodeId) + { + try + { + nodeId = NodeId.Parse(value); + return !nodeId.IsNull; + } + catch (ServiceResultException) + { + nodeId = NodeId.Null; + return false; + } + catch (FormatException) + { + nodeId = NodeId.Null; + return false; + } + catch (ArgumentException) + { + // NodeId.Parse throws ArgumentException (not ServiceResultException) + // for a portable "nsu=" / missing-identifier form; treat it the + // same as any other unparseable text so TryResolveNodeId can fall + // back to ExpandedNodeId + namespace table resolution. + nodeId = NodeId.Null; + return false; + } + } + + /// + /// A running native OPC UA subscription backing an observe or event + /// channel. Disposing it removes the monitored item's notification + /// handler and deletes the subscription server-side (via + /// ) before releasing the + /// local , so no session/subscription leaks. + /// + private sealed class OpcUaMonitoredItemSubscription : IWotSubscription + { + public OpcUaMonitoredItemSubscription( + WotCompiledForm form, + ISession session, + Subscription subscription, + MonitoredItem item, + MonitoredItemNotificationEventHandler handler) + { + Form = form; + m_session = session; + m_subscription = subscription; + m_item = item; + m_handler = handler; + } + + public WotCompiledForm Form { get; } + + public async ValueTask DisposeAsync() + { + m_item.Notification -= m_handler; + try + { + await m_session.RemoveSubscriptionAsync(m_subscription, CancellationToken.None) + .ConfigureAwait(false); + } + catch (ServiceResultException) + { + // Best-effort server-side cleanup; the session may already + // be closed or the subscription already removed. + } + m_subscription.Dispose(); + } + + private readonly ISession m_session; + private readonly Subscription m_subscription; + private readonly MonitoredItem m_item; + private readonly MonitoredItemNotificationEventHandler m_handler; + } + + private readonly ISession m_session; + private readonly bool m_disposeSession; + private readonly WotCompiledForm m_form; + private readonly WotExecutorContext m_context; + private readonly OpcUaWotBindingOptions m_options; + private readonly string m_nodeId; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingExecutor.cs new file mode 100644 index 0000000000..b90bd4a2d9 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingExecutor.cs @@ -0,0 +1,85 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Binding.OpcUa +{ + /// + /// Executes OPC UA WoT binding forms compiled by the + /// by connecting an to + /// the target endpoint through the injectable session factory. + /// + public sealed class OpcUaWotBindingExecutor : IWotBindingExecutor + { + /// Initializes a new OPC UA executor. + public OpcUaWotBindingExecutor(OpcUaWotBindingOptions options) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("opc.opcua", "10101", OpcUaBindingPlanner.BindingUri, "OPC UA WoT Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + => form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + + /// + public async ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + if (m_options.SessionFactory is null) + { + throw new InvalidOperationException( + "No OPC UA session factory is configured on the executor options."); + } + string endpoint = string.IsNullOrEmpty(form.Endpoint.BaseUri) + ? form.Endpoint.Scheme + "://" + (form.Endpoint.Host ?? string.Empty) + : form.Endpoint.BaseUri; + ISession session = await m_options.SessionFactory(endpoint, cancellationToken).ConfigureAwait(false); + return new OpcUaWotBindingChannel(session, m_options.DisposeSession, form, context, m_options); + } + + private readonly OpcUaWotBindingOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingOptions.cs b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingOptions.cs new file mode 100644 index 0000000000..b6d4fd6581 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/OpcUaWotBindingOptions.cs @@ -0,0 +1,74 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; +using Opc.Ua.Client; + +namespace Opc.Ua.WotCon.Binding.OpcUa +{ + /// + /// Options for the OPC UA WoT binding executor. The session factory connects a + /// client session to the target endpoint and is injectable so callers control + /// the application configuration, security and identity. + /// + public sealed class OpcUaWotBindingOptions + { + /// + /// Gets or sets the factory that connects an to the + /// supplied opc.tcp endpoint. It is required for execution. + /// + public Func>? SessionFactory { get; set; } + + /// + /// Gets or sets whether the executor disposes the session when the channel + /// is disposed. Set to false when a shared, caller-owned session is + /// returned by the factory. + /// + public bool DisposeSession { get; set; } = true; + + /// + /// Gets or sets the sampling / publishing interval used for observe and + /// event subscriptions. Observe and event notifications are delivered by + /// a native OPC UA / + /// pair (Part 4 §5.13 / §5.12); this bounds how fast the server samples + /// and publishes, not a client-side poll. + /// + public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1); + + /// + /// Gets or sets the bounded monitored-item queue size requested for + /// event subscriptions, so a burst of events cannot grow the server-side + /// queue without bound. Property observe monitored items always request + /// a queue size of 1 (only the latest value is relevant). + /// + public uint EventQueueSize { get; set; } = 10; + } +} diff --git a/src/Opc.Ua.WotCon.Binding.OpcUa/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Binding.OpcUa/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding.OpcUa/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.WotCon.Binding/IWotBinderRegistry.cs b/src/Opc.Ua.WotCon.Binding/IWotBinderRegistry.cs new file mode 100644 index 0000000000..78ef22c5f9 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/IWotBinderRegistry.cs @@ -0,0 +1,114 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// The runtime-neutral binder seam the materialization coordinator uses to + /// discover binding capabilities and to prepare / activate / deactivate + /// bindings around a projection. Concrete binders and executors are registered + /// with ; the default + /// reports no binders, so every form is + /// unsupported (strict closures fail; non-strict closures materialize degraded + /// nodes with ). + /// + public interface IWotBinderRegistry + { + /// Gets the binding capability snapshots advertised by the registry. + IReadOnlyList Capabilities { get; } + + /// + /// Validates and compiles a resource's forms into an immutable binding + /// plan. Prepare is side-effect free: it classifies forms as supported or + /// unsupported and compiles supported forms, but never performs transport + /// I/O. + /// + WotBindingPlan Prepare(WotBindingPlanRequest request); + + /// + /// Activates a prepared plan after the projection has been committed as the + /// active generation. + /// + ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default); + + /// Deactivates a plan when its projection is retired. + ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default); + } + + /// + /// The default binder registry: it advertises no capabilities and treats every + /// form as unsupported. It provides the "no concrete network protocol" + /// baseline used when no binders are registered. + /// + public sealed class NullWotBinderRegistry : IWotBinderRegistry + { + /// Gets the shared instance. + public static NullWotBinderRegistry Instance { get; } = new NullWotBinderRegistry(); + + /// + public IReadOnlyList Capabilities { get; } + = Array.Empty(); + + /// + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + if (request.Forms.IsEmpty) + { + return WotBindingPlan.Empty; + } + return new WotBindingPlan( + request.ResourceXid, + ImmutableArray.Empty, + ImmutableArray.Empty, + request.Forms, + ImmutableArray.Create(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.NonExecutableBinding, + "No binder is registered; affordance forms are materialized as " + + "degraded nodes (BadConfigurationError) or fail a strict closure."))); + } + + /// + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + => default; + + /// + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + => default; + } +} diff --git a/src/Opc.Ua.WotCon.Binding/IWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Binding/IWotBindingExecutor.cs new file mode 100644 index 0000000000..724379f273 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/IWotBindingExecutor.cs @@ -0,0 +1,223 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// Runtime context handed to an executor while it activates a compiled form: + /// the credential provider used to resolve secret-free references and the + /// safety bounds it must enforce. + /// + public sealed class WotExecutorContext + { + /// Initializes a new executor context. + public WotExecutorContext( + IWotCredentialProvider? credentials = null, + IWotCodecRegistry? codecs = null, + WotBindingBounds? bounds = null) + { + Credentials = credentials ?? NullWotCredentialProvider.Instance; + Codecs = codecs ?? WotPayloadCodecRegistry.Default; + Bounds = bounds ?? WotBindingBounds.Default; + } + + /// Gets the credential provider. + public IWotCredentialProvider Credentials { get; } + + /// Gets the codec registry. + public IWotCodecRegistry Codecs { get; } + + /// Gets the enforced safety bounds. + public WotBindingBounds Bounds { get; } + } + + /// A push notification from an observe / event channel. + public sealed class WotNotification + { + /// Initializes a new notification. + /// + /// The notified value. For a property observe this is the reported + /// . For an event this is a deterministic + /// projection of the event (see for the full + /// per-field envelope) carrying the mapped and + /// the event's source / receive timestamps. + /// + /// + /// The optional event field envelope: every EventFilter + /// select-clause field (browse path, '/'-joined for nested paths) + /// mapped to its own . Empty for a property + /// observe notification. + /// + public WotNotification( + DataValue value, IReadOnlyDictionary? eventFields = null) + { + Value = value; + EventFields = eventFields ?? ImmutableDictionary.Empty; + } + + /// Gets the notified value together with its status and timestamps. + public DataValue Value { get; } + + /// + /// Gets the event field envelope, keyed by the select-clause browse + /// path. Empty for a property observe notification. + /// + public IReadOnlyDictionary EventFields { get; } + } + + /// The result of a read operation. + public sealed class WotReadResult + { + /// Initializes a new read result. + public WotReadResult(StatusCode status, DataValue value, string? error = null) + { + Status = status; + Value = value; + Error = error; + } + + /// Gets the mapped status code. + public StatusCode Status { get; } + + /// Gets the read value with status and timestamps. + public DataValue Value { get; } + + /// Gets the error message on failure, if any. + public string? Error { get; } + + /// Gets whether the operation succeeded. + public bool Success => StatusCode.IsGood(Status); + } + + /// The result of a write operation. + public sealed class WotWriteResult + { + /// Initializes a new write result. + public WotWriteResult(StatusCode status, string? error = null) + { + Status = status; + Error = error; + } + + /// Gets the mapped status code. + public StatusCode Status { get; } + + /// Gets the error message on failure, if any. + public string? Error { get; } + + /// Gets whether the operation succeeded. + public bool Success => StatusCode.IsGood(Status); + } + + /// The result of an action invocation. + public sealed class WotInvokeResult + { + /// Initializes a new invoke result. + public WotInvokeResult(StatusCode status, IReadOnlyList? outputs = null, string? error = null) + { + Status = status; + Outputs = outputs ?? Array.Empty(); + Error = error; + } + + /// Gets the mapped status code. + public StatusCode Status { get; } + + /// Gets the action outputs in declaration order. + public IReadOnlyList Outputs { get; } + + /// Gets the error message on failure, if any. + public string? Error { get; } + + /// Gets whether the operation succeeded. + public bool Success => StatusCode.IsGood(Status); + } + + /// A running observe / event subscription. Disposing it stops delivery. + public interface IWotSubscription : IAsyncDisposable + { + /// Gets the compiled form the subscription observes. + WotCompiledForm Form { get; } + } + + /// + /// A live per-form binding channel opened by an executor. It exposes the + /// read / write / invoke / observe / event operations the binding supports. + /// Operations not supported by the channel's compiled operation return a + /// result. Disposing the channel + /// releases the underlying transport resource. + /// + public interface IWotBindingChannel : IAsyncDisposable + { + /// Gets the compiled form the channel binds. + WotCompiledForm Form { get; } + + /// Reads the current value. + ValueTask ReadAsync(CancellationToken cancellationToken = default); + + /// Writes a value. + ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default); + + /// Invokes an action with ordered inputs. + ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default); + + /// Observes property-value changes. + ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default); + + /// Subscribes to events. + ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default); + } + + /// + /// Executes a compiled binding form against a live transport. Executors are + /// registered independently from planners so a protocol can be validated + /// without an executor and executed once one is present. + /// + public interface IWotBindingExecutor + { + /// Gets the identity of the binder this executor serves. + WotBindingIdentity Identity { get; } + + /// Gets whether the executor can run the supplied compiled form. + bool CanExecute(WotCompiledForm form); + + /// Opens a live channel for the supplied compiled form. + ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/IWotBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/IWotBindingPlanner.cs new file mode 100644 index 0000000000..1a319950fd --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/IWotBindingPlanner.cs @@ -0,0 +1,216 @@ +/* ======================================================================== + * 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.Immutable; +using System.Linq; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// Read-only context handed to a planner while it validates and compiles a + /// form. It exposes the document security definitions (secret-free), the codec + /// registry, the document kind, the Thing base URI for relative href + /// resolution and the applied safety bounds. + /// + public sealed class WotBindingPlanContext + { + /// Initializes a new plan context. + public WotBindingPlanContext( + ImmutableDictionary? securityDefinitions = null, + IWotCodecRegistry? codecs = null, + WoTDocumentKindEnum documentKind = WoTDocumentKindEnum.ThingDescription, + string? baseUri = null, + WotBindingBounds? bounds = null) + { + SecurityDefinitions = securityDefinitions ?? ImmutableDictionary.Empty; + Codecs = codecs ?? WotPayloadCodecRegistry.Default; + DocumentKind = documentKind; + BaseUri = baseUri; + Bounds = bounds ?? WotBindingBounds.Default; + } + + /// Gets the secret-free security definitions declared by the document. + public ImmutableDictionary SecurityDefinitions { get; } + + /// Gets the codec registry used to select payload codecs. + public IWotCodecRegistry Codecs { get; } + + /// Gets the document kind being compiled. + public WoTDocumentKindEnum DocumentKind { get; } + + /// Gets the Thing base URI used to resolve relative hrefs, if any. + public string? BaseUri { get; } + + /// Gets the applied safety bounds. + public WotBindingBounds Bounds { get; } + } + + /// + /// The immutable compiled plan for one supported (form, operation) pair. It + /// carries the endpoint, addressing, operation and payload metadata plus the + /// secret-free credential references the runtime resolves at activation time. + /// A non-executable entry is a validated plan for which no runtime executor is + /// available (for example a BACnet, PROFINET or LoRaWAN binding). + /// + public sealed class WotCompiledForm + { + /// Initializes a new immutable compiled form. + public WotCompiledForm( + WotBindingIdentity binding, + WotAffordanceKind affordanceKind, + string affordanceName, + string jsonPointer, + WoTBindingCapabilityEnum operation, + string opToken, + WotEndpointDescriptor endpoint, + WotAddressingDescriptor addressing, + WotOperationDescriptor operationInfo, + WotPayloadDescriptor payload, + ImmutableArray security, + bool isExecutable) + { + Binding = binding ?? throw new ArgumentNullException(nameof(binding)); + AffordanceKind = affordanceKind; + AffordanceName = affordanceName ?? string.Empty; + JsonPointer = jsonPointer ?? string.Empty; + Operation = operation; + OpToken = opToken ?? string.Empty; + Endpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); + Addressing = addressing ?? throw new ArgumentNullException(nameof(addressing)); + OperationInfo = operationInfo ?? throw new ArgumentNullException(nameof(operationInfo)); + Payload = payload ?? throw new ArgumentNullException(nameof(payload)); + Security = security.IsDefault ? ImmutableArray.Empty : security; + IsExecutable = isExecutable; + } + + /// Gets the identity of the binder that compiled the form. + public WotBindingIdentity Binding { get; } + + /// Gets the affordance kind. + public WotAffordanceKind AffordanceKind { get; } + + /// Gets the affordance name. + public string AffordanceName { get; } + + /// Gets the JSON Pointer of the originating form. + public string JsonPointer { get; } + + /// Gets the resolved capability operation. + public WoTBindingCapabilityEnum Operation { get; } + + /// Gets the originating WoT op token. + public string OpToken { get; } + + /// Gets the compiled endpoint metadata. + public WotEndpointDescriptor Endpoint { get; } + + /// Gets the compiled addressing metadata. + public WotAddressingDescriptor Addressing { get; } + + /// Gets the compiled operation metadata. + public WotOperationDescriptor OperationInfo { get; } + + /// Gets the compiled payload metadata. + public WotPayloadDescriptor Payload { get; } + + /// Gets the secret-free credential references for the operation. + public ImmutableArray Security { get; } + + /// Gets whether a runtime executor is available for the entry. + public bool IsExecutable { get; } + + /// Returns a copy of this entry with the supplied executability. + public WotCompiledForm WithExecutable(bool isExecutable) + { + if (isExecutable == IsExecutable) + { + return this; + } + return new WotCompiledForm( + Binding, AffordanceKind, AffordanceName, JsonPointer, Operation, OpToken, + Endpoint, Addressing, OperationInfo, Payload, Security, isExecutable); + } + } + + /// + /// The result of compiling a single form with a binder: the compiled entries + /// (one per supported operation), the structured diagnostics and whether the + /// form was validated (supported) at all. + /// + public sealed class WotBindingCompilation + { + /// Initializes a new compilation result. + public WotBindingCompilation( + bool isSupported, + ImmutableArray entries, + ImmutableArray diagnostics) + { + IsSupported = isSupported; + Entries = entries.IsDefault ? ImmutableArray.Empty : entries; + Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; + } + + /// + /// Gets whether the form was validated and compiled. A supported form has + /// at least one compiled entry and no error diagnostics. + /// + public bool IsSupported { get; } + + /// Gets the compiled entries. + public ImmutableArray Entries { get; } + + /// Gets the structured diagnostics. + public ImmutableArray Diagnostics { get; } + + /// Gets whether any error diagnostic was produced. + public bool HasErrors => Diagnostics.Any(d => d.IsError); + + /// Creates an unsupported result (a binder declined or rejected the form). + public static WotBindingCompilation Unsupported(params WotBindingDiagnostic[] diagnostics) + => new WotBindingCompilation(false, ImmutableArray.Empty, + diagnostics is null ? ImmutableArray.Empty : diagnostics.ToImmutableArray()); + + /// Creates a supported result. + public static WotBindingCompilation Supported( + ImmutableArray entries, ImmutableArray diagnostics) + => new WotBindingCompilation(true, entries, diagnostics); + } + + /// + /// Validates and compiles WoT interaction forms into immutable binding plans. + /// A planner performs no transport I/O, so a planner-only binder can validate + /// and compile forms for protocols the runtime cannot execute. + /// + public interface IWotBindingPlanner + { + /// Validates and compiles a single form into a binding plan. + WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/IWotCredentialProvider.cs b/src/Opc.Ua.WotCon.Binding/IWotCredentialProvider.cs new file mode 100644 index 0000000000..afed9816b6 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/IWotCredentialProvider.cs @@ -0,0 +1,292 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Security.Cryptography.X509Certificates; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding +{ + /// The WoT security scheme kind referenced by a form (no secrets). + public enum WotSecurityScheme + { + /// No security (nosec). + NoSecurity, + + /// HTTP Basic authentication. + Basic, + + /// HTTP Digest authentication. + Digest, + + /// Bearer token authentication. + Bearer, + + /// API-key authentication. + ApiKey, + + /// Pre-shared-key authentication. + Psk, + + /// OAuth 2.0 authentication. + OAuth2, + + /// Automatic security provisioned out-of-band. + Auto, + + /// A combination of other schemes. + Combo, + + /// A scheme not otherwise enumerated. + Other + } + + /// + /// An immutable, secret-free security definition parsed from a Thing + /// Description securityDefinitions entry. It records only the scheme + /// kind and where the credential is carried (for example an HTTP header + /// name); the actual secret is never present in the document or on registry + /// nodes and is resolved at runtime by an . + /// + public sealed class WotSecurityDefinition + { + /// The well-known no-security definition. + public static WotSecurityDefinition NoSecurity { get; } = + new WotSecurityDefinition("nosec_sc", WotSecurityScheme.NoSecurity, null, null); + + /// Initializes a new immutable security definition. + public WotSecurityDefinition(string name, WotSecurityScheme scheme, string? @in, string? parameterName) + { + Name = name ?? string.Empty; + Scheme = scheme; + In = @in; + ParameterName = parameterName; + } + + /// Gets the definition name (the scheme reference used by forms). + public string Name { get; } + + /// Gets the security scheme kind. + public WotSecurityScheme Scheme { get; } + + /// Gets where the credential is carried (header, query, ...). + public string? In { get; } + + /// Gets the parameter name (for example the header name), if any. + public string? ParameterName { get; } + + /// Parses a securityDefinitions entry into a definition. + public static WotSecurityDefinition Parse(string name, JsonElement definition) + { + WotSecurityScheme scheme = WotSecurityScheme.Other; + string? @in = null; + string? parameterName = null; + if (definition.ValueKind == JsonValueKind.Object) + { + if (definition.TryGetProperty("scheme", out JsonElement schemeElement) && + schemeElement.ValueKind == JsonValueKind.String) + { + scheme = MapScheme(schemeElement.GetString()); + } + if (definition.TryGetProperty("in", out JsonElement inElement) && + inElement.ValueKind == JsonValueKind.String) + { + @in = inElement.GetString(); + } + if (definition.TryGetProperty("name", out JsonElement nameElement) && + nameElement.ValueKind == JsonValueKind.String) + { + parameterName = nameElement.GetString(); + } + } + return new WotSecurityDefinition(name, scheme, @in, parameterName); + } + + private static WotSecurityScheme MapScheme(string? scheme) + { + return scheme switch + { + "nosec" => WotSecurityScheme.NoSecurity, + "basic" => WotSecurityScheme.Basic, + "digest" => WotSecurityScheme.Digest, + "bearer" => WotSecurityScheme.Bearer, + "apikey" => WotSecurityScheme.ApiKey, + "psk" => WotSecurityScheme.Psk, + "oauth2" => WotSecurityScheme.OAuth2, + "auto" => WotSecurityScheme.Auto, + "combo" => WotSecurityScheme.Combo, + _ => WotSecurityScheme.Other + }; + } + } + + /// + /// A secret-free reference the runtime resolves against an out-of-band secret + /// store. It carries the scheme name and endpoint context so a provider can + /// select the correct credential without any secret ever appearing in the + /// Thing Description or on registry nodes. + /// + public sealed class WotCredentialReference + { + /// Initializes a new immutable credential reference. + public WotCredentialReference( + string schemeName, + WotSecurityScheme scheme, + string bindingUri, + string? endpoint, + string? @in = null, + string? parameterName = null) + { + SchemeName = schemeName ?? string.Empty; + Scheme = scheme; + BindingUri = bindingUri ?? string.Empty; + Endpoint = endpoint; + In = @in; + ParameterName = parameterName; + } + + /// Gets the referenced security scheme name. + public string SchemeName { get; } + + /// Gets the security scheme kind. + public WotSecurityScheme Scheme { get; } + + /// Gets the binding vocabulary URI requesting the credential. + public string BindingUri { get; } + + /// Gets the endpoint the credential is scoped to, if any. + public string? Endpoint { get; } + + /// Gets where the credential is carried, if known. + public string? In { get; } + + /// Gets the parameter / header name, if known. + public string? ParameterName { get; } + + /// Creates a reference from a security definition and endpoint. + public static WotCredentialReference FromDefinition( + WotSecurityDefinition definition, string bindingUri, string? endpoint) + => new WotCredentialReference( + definition.Name, definition.Scheme, bindingUri, endpoint, definition.In, definition.ParameterName); + } + + /// + /// Resolved credential material produced only at runtime by an + /// . Instances are short-lived and never + /// serialized to the Thing Description or registry nodes. The material is + /// expressed as headers, query parameters and opaque properties so it can + /// drive HTTP, MQTT and other transports uniformly. + /// + public sealed class WotCredential + { + /// Initializes a new resolved credential. + public WotCredential( + WotSecurityScheme scheme, + ImmutableDictionary? headers = null, + ImmutableDictionary? queryParameters = null, + ImmutableDictionary? properties = null, + X509Certificate2? clientCertificate = null, + IReadOnlyList? trustedCertificates = null) + { + Scheme = scheme; + Headers = headers ?? ImmutableDictionary.Empty; + QueryParameters = queryParameters ?? ImmutableDictionary.Empty; + Properties = properties ?? ImmutableDictionary.Empty; + ClientCertificate = clientCertificate; + TrustedCertificates = trustedCertificates is null + ? ImmutableArray.Empty + : trustedCertificates.ToImmutableArray(); + } + + /// Gets the scheme this credential satisfies. + public WotSecurityScheme Scheme { get; } + + /// Gets transport headers to apply (for example Authorization). + public ImmutableDictionary Headers { get; } + + /// Gets query parameters to apply (for example an API key). + public ImmutableDictionary QueryParameters { get; } + + /// + /// Gets opaque credential properties (for example username / + /// password for MQTT, or a token reference id). + /// + public ImmutableDictionary Properties { get; } + + /// + /// Gets the resolved client certificate used for mutual TLS (for example + /// an mqtts connection), or null when none is configured. + /// Runtime-only material that is never serialized to the Thing Description + /// or registry nodes. + /// + public X509Certificate2? ClientCertificate { get; } + + /// + /// Gets the resolved trust anchors used to validate the peer's TLS + /// certificate (for example an mqtts broker). Empty when the + /// transport should rely on the platform default trust store. Runtime-only + /// material that is never serialized to the Thing Description or registry + /// nodes. + /// + public ImmutableArray TrustedCertificates { get; } + } + + /// + /// Resolves secret-free credential references into runtime credential + /// material. Implementations look the secret up in a trust / secret store + /// out-of-band; the Thing Description and registry nodes only ever carry the + /// scheme reference. + /// + public interface IWotCredentialProvider + { + /// + /// Resolves a credential for the supplied reference, or null when + /// no credential is required (for example nosec) or none is + /// configured. + /// + ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default); + } + + /// A credential provider that resolves nothing (no security). + public sealed class NullWotCredentialProvider : IWotCredentialProvider + { + /// Gets the shared instance. + public static NullWotCredentialProvider Instance { get; } = new NullWotCredentialProvider(); + + /// + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + => new ValueTask((WotCredential?)null); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/IWotPayloadCodec.cs b/src/Opc.Ua.WotCon.Binding/IWotPayloadCodec.cs new file mode 100644 index 0000000000..06c627dbd4 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/IWotPayloadCodec.cs @@ -0,0 +1,381 @@ +/* ======================================================================== + * 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.WotCon.Binding +{ + /// The result of encoding a value to a payload. + public sealed class WotEncodeResult + { + private WotEncodeResult(bool success, ReadOnlyMemory data, string? error) + { + Success = success; + Data = data; + Error = error; + } + + /// Gets whether encoding succeeded. + public bool Success { get; } + + /// Gets the encoded bytes. + public ReadOnlyMemory Data { get; } + + /// Gets the error message on failure. + public string? Error { get; } + + /// Creates a successful encode result. + public static WotEncodeResult Ok(ReadOnlyMemory data) => new WotEncodeResult(true, data, null); + + /// Creates a failed encode result. + public static WotEncodeResult Fail(string error) + => new WotEncodeResult(false, ReadOnlyMemory.Empty, error); + } + + /// The result of decoding a payload to a value. + public sealed class WotDecodeResult + { + private WotDecodeResult(bool success, Variant value, string? error) + { + Success = success; + Value = value; + Error = error; + } + + /// Gets whether decoding succeeded. + public bool Success { get; } + + /// Gets the decoded value. + public Variant Value { get; } + + /// Gets the error message on failure. + public string? Error { get; } + + /// Creates a successful decode result. + public static WotDecodeResult Ok(Variant value) => new WotDecodeResult(true, value, null); + + /// Creates a failed decode result. + public static WotDecodeResult Fail(string error) => new WotDecodeResult(false, Variant.Null, error); + } + + /// + /// Encodes and decodes payloads between OPC UA values and transport bytes for + /// a content type. Codecs are reflection-free and AOT-safe. + /// + public interface IWotPayloadCodec + { + /// Gets the stable codec id (recorded on the compiled plan). + string Id { get; } + + /// Gets whether the codec handles the supplied content type. + bool CanHandle(string? contentType); + + /// Encodes a value to bytes for the supplied payload metadata. + WotEncodeResult Encode(Variant value, WotPayloadDescriptor payload); + + /// Decodes bytes into a value for the supplied payload metadata. + WotDecodeResult Decode(ReadOnlyMemory data, WotPayloadDescriptor payload); + } + + /// Selects a payload codec for a content type. + public interface IWotCodecRegistry + { + /// Attempts to select a codec for the supplied content type. + bool TrySelect(string? contentType, out IWotPayloadCodec codec); + } + + /// + /// The default codec registry: it selects the first registered codec whose + /// returns true, and + /// ships JSON, plain-text and octet-stream codecs. Additional codecs can be + /// registered by protocol executors. + /// + public sealed class WotPayloadCodecRegistry : IWotCodecRegistry + { + /// Initializes a registry with the built-in codecs. + public WotPayloadCodecRegistry() + { + m_codecs.Add(JsonWotPayloadCodec.Instance); + m_codecs.Add(TextWotPayloadCodec.Instance); + m_codecs.Add(OctetStreamWotPayloadCodec.Instance); + } + + /// Gets the shared registry with the built-in codecs. + public static WotPayloadCodecRegistry Default { get; } = new WotPayloadCodecRegistry(); + + /// Registers a codec at the front of the selection order. + public WotPayloadCodecRegistry Register(IWotPayloadCodec codec) + { + if (codec is null) + { + throw new ArgumentNullException(nameof(codec)); + } + m_codecs.Insert(0, codec); + return this; + } + + /// + public bool TrySelect(string? contentType, out IWotPayloadCodec codec) + { + foreach (IWotPayloadCodec candidate in m_codecs) + { + if (candidate.CanHandle(contentType)) + { + codec = candidate; + return true; + } + } + codec = JsonWotPayloadCodec.Instance; + return false; + } + + private readonly List m_codecs = new List(); + } + + /// A reflection-free JSON scalar payload codec (application/json). + public sealed class JsonWotPayloadCodec : IWotPayloadCodec + { + /// Gets the shared instance. + public static JsonWotPayloadCodec Instance { get; } = new JsonWotPayloadCodec(); + + /// + public string Id => "json"; + + /// + public bool CanHandle(string? contentType) + { + if (string.IsNullOrEmpty(contentType)) + { + return true; + } + string media = MediaType(contentType!); + return media.Equals("application/json", StringComparison.OrdinalIgnoreCase) || + media.EndsWith("+json", StringComparison.OrdinalIgnoreCase); + } + + /// + public WotEncodeResult Encode(Variant value, WotPayloadDescriptor payload) + { + try + { + using var buffer = new MemoryStream(); + using (var writer = new Utf8JsonWriter(buffer)) + { + WriteValue(writer, value.AsBoxedObject()); + } + return WotEncodeResult.Ok(buffer.ToArray()); + } + catch (Exception ex) when (ex is JsonException or InvalidOperationException or NotSupportedException) + { + return WotEncodeResult.Fail(ex.Message); + } + } + + /// + public WotDecodeResult Decode(ReadOnlyMemory data, WotPayloadDescriptor payload) + { + if (data.IsEmpty) + { + return WotDecodeResult.Ok(Variant.Null); + } + try + { + using JsonDocument document = JsonDocument.Parse(data); + JsonElement root = document.RootElement; + switch (root.ValueKind) + { + case JsonValueKind.True: + case JsonValueKind.False: + return WotDecodeResult.Ok(new Variant(root.GetBoolean())); + case JsonValueKind.String: + return WotDecodeResult.Ok(new Variant(root.GetString() ?? string.Empty)); + case JsonValueKind.Null: + return WotDecodeResult.Ok(Variant.Null); + case JsonValueKind.Number: + if (root.TryGetInt64(out long l)) + { + return WotDecodeResult.Ok(new Variant(l)); + } + if (root.TryGetDouble(out double d)) + { + return WotDecodeResult.Ok(new Variant(d)); + } + return WotDecodeResult.Ok(new Variant(root.GetRawText())); + default: + // Objects and arrays are preserved as their raw JSON text. + return WotDecodeResult.Ok(new Variant(root.GetRawText())); + } + } + catch (JsonException ex) + { + return WotDecodeResult.Fail(ex.Message); + } + } + + private static void WriteValue(Utf8JsonWriter writer, object? value) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case string s: + writer.WriteStringValue(s); + break; + case sbyte sb: + writer.WriteNumberValue(sb); + break; + case byte by: + writer.WriteNumberValue(by); + break; + case short sh: + writer.WriteNumberValue(sh); + break; + case ushort us: + writer.WriteNumberValue(us); + break; + case int i: + writer.WriteNumberValue(i); + break; + case uint ui: + writer.WriteNumberValue(ui); + break; + case long lo: + writer.WriteNumberValue(lo); + break; + case ulong ul: + writer.WriteNumberValue(ul); + break; + case float f: + writer.WriteNumberValue(f); + break; + case double dou: + writer.WriteNumberValue(dou); + break; + case decimal de: + writer.WriteNumberValue(de); + break; + default: + writer.WriteStringValue(Convert.ToString(value, CultureInfo.InvariantCulture)); + break; + } + } + + private static string MediaType(string contentType) + { + int semicolon = -1; + for (int i = 0; i < contentType.Length; i++) + { + if (contentType[i] == ';') + { + semicolon = i; + break; + } + } + return (semicolon >= 0 ? contentType.Substring(0, semicolon) : contentType).Trim(); + } + } + + /// A plain-text payload codec (text/plain). + public sealed class TextWotPayloadCodec : IWotPayloadCodec + { + /// Gets the shared instance. + public static TextWotPayloadCodec Instance { get; } = new TextWotPayloadCodec(); + + /// + public string Id => "text"; + + /// + public bool CanHandle(string? contentType) + => !string.IsNullOrEmpty(contentType) && + contentType!.StartsWith("text/", StringComparison.OrdinalIgnoreCase); + + /// + public WotEncodeResult Encode(Variant value, WotPayloadDescriptor payload) + { + object? boxed = value.AsBoxedObject(); + string text = boxed is null + ? string.Empty + : Convert.ToString(boxed, CultureInfo.InvariantCulture) ?? string.Empty; + return WotEncodeResult.Ok(Encoding.UTF8.GetBytes(text)); + } + + /// + public WotDecodeResult Decode(ReadOnlyMemory data, WotPayloadDescriptor payload) + { + string text = Encoding.UTF8.GetString(data.ToArray()); + return WotDecodeResult.Ok(new Variant(text)); + } + } + + /// An octet-stream payload codec (application/octet-stream). + public sealed class OctetStreamWotPayloadCodec : IWotPayloadCodec + { + /// Gets the shared instance. + public static OctetStreamWotPayloadCodec Instance { get; } = new OctetStreamWotPayloadCodec(); + + /// + public string Id => "octet-stream"; + + /// + public bool CanHandle(string? contentType) + => !string.IsNullOrEmpty(contentType) && + contentType!.StartsWith("application/octet-stream", StringComparison.OrdinalIgnoreCase); + + /// + public WotEncodeResult Encode(Variant value, WotPayloadDescriptor payload) + { + if (value.TryGetValue(out ByteString byteString)) + { + return WotEncodeResult.Ok(byteString.Memory.ToArray()); + } + object? boxed = value.AsBoxedObject(); + if (boxed is byte[] bytes) + { + return WotEncodeResult.Ok(bytes); + } + string text = boxed is null + ? string.Empty + : Convert.ToString(boxed, CultureInfo.InvariantCulture) ?? string.Empty; + return WotEncodeResult.Ok(Encoding.UTF8.GetBytes(text)); + } + + /// + public WotDecodeResult Decode(ReadOnlyMemory data, WotPayloadDescriptor payload) + => WotDecodeResult.Ok(new Variant(new ByteString(data.ToArray()))); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/IWotProtocolBinder.cs b/src/Opc.Ua.WotCon.Binding/IWotProtocolBinder.cs new file mode 100644 index 0000000000..a7c1eac71a --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/IWotProtocolBinder.cs @@ -0,0 +1,323 @@ +/* ======================================================================== + * 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.Collections.Immutable; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// A replaceable protocol binder: the composition of a stable identity, a + /// capability snapshot, deterministic identification and a planner. Binders + /// are injected independently and selected by identity and pinned rules, so + /// multiple versions of the same binding can coexist. Concrete executors are + /// registered separately, so a binder can validate and compile plans for + /// protocols the runtime cannot execute. + /// + public interface IWotProtocolBinder + { + /// Gets the stable binder identity (id + version). + WotBindingIdentity Identity { get; } + + /// Gets the version-pinned capability snapshot. + WotBindingCapability Capability { get; } + + /// Gets the deterministic identification rules. + IWotBindingIdentification Identification { get; } + + /// Gets the form validator / compiler. + IWotBindingPlanner Planner { get; } + } + + /// + /// Base class for protocol binders. It implements + /// , + /// and and provides shared validation helpers + /// (scheme identification, operation compatibility, codec selection and + /// secret-free security resolution) so concrete planners focus on the protocol + /// specifics. + /// + public abstract class WotProtocolBinderBase : IWotProtocolBinder, IWotBindingIdentification, IWotBindingPlanner + { + /// + public abstract WotBindingIdentity Identity { get; } + + /// + public abstract WotBindingCapability Capability { get; } + + /// + public IWotBindingIdentification Identification => this; + + /// + public IWotBindingPlanner Planner => this; + + /// + public abstract WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context); + + /// + public abstract WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context); + + /// Gets the URI schemes the binder handles for scheme-based identification. + protected abstract IReadOnlyCollection Schemes { get; } + + /// + /// A default scheme / vocabulary / explicit-pin identification helper. An + /// explicit pin on the resource wins with + /// ; otherwise a form + /// whose href scheme is handled matches with + /// , and a form carrying the + /// binding's vocabulary prefix matches with the stronger + /// . + /// + protected WotBindingMatch MatchStandard( + WotAffordanceForm form, WotBindingSelectionContext context, string? vocabularyPrefix) + { + if (context.IsPinned(Identity)) + { + return WotBindingMatch.Match(WotBindingMatchKind.ExplicitBindingId); + } + WotBindingMatch best = WotBindingMatch.NoMatch; + if (SchemeMatches(form)) + { + best = WotBindingMatch.Match(WotBindingMatchKind.Scheme); + } + if (!string.IsNullOrEmpty(vocabularyPrefix) && HasVocabularyPrefix(form, vocabularyPrefix!)) + { + WotBindingMatch vocabulary = WotBindingMatch.Match(WotBindingMatchKind.Vocabulary); + if (vocabulary.Priority > best.Priority) + { + best = vocabulary; + } + } + return best; + } + + /// Gets whether the form's href scheme is handled by this binder. + protected bool SchemeMatches(WotAffordanceForm form) + { + string? scheme = SchemeOf(form.Href); + if (scheme is null) + { + return false; + } + foreach (string handled in Schemes) + { + if (string.Equals(scheme, handled, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + /// Gets whether the form object carries any member with the vocabulary prefix. + protected static bool HasVocabularyPrefix(WotAffordanceForm form, string prefix) + { + if (form.FormElement.ValueKind != System.Text.Json.JsonValueKind.Object) + { + return false; + } + foreach (System.Text.Json.JsonProperty property in form.FormElement.EnumerateObject()) + { + if (property.Name.StartsWith(prefix, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + + /// Attempts to parse an href as an absolute URI. + protected static bool TryParseUri(string href, out Uri uri) + => Uri.TryCreate(href, UriKind.Absolute, out uri!) && uri is not null; + + /// Builds an endpoint descriptor from a parsed URI authority. + protected static WotEndpointDescriptor MakeEndpoint(Uri uri) + => new WotEndpointDescriptor( + uri.Scheme, + string.IsNullOrEmpty(uri.Host) ? null : uri.Host, + uri.Port, + uri.GetLeftPart(UriPartial.Authority)); + + /// + /// Builds an endpoint descriptor from an href, or a synthetic descriptor + /// carrying the raw href when it is not a parseable absolute URI (used by + /// document-level, non-executable bindings). + /// + protected static WotEndpointDescriptor MakeEndpointOrSynthetic(string? href, string scheme) + { + if (!string.IsNullOrEmpty(href) && TryParseUri(href!, out Uri uri)) + { + return MakeEndpoint(uri); + } + return new WotEndpointDescriptor(scheme, null, -1, href ?? string.Empty); + } + + /// Extracts the lower-case URI scheme from an href, if present. + protected static string? SchemeOf(string? href) + { + if (string.IsNullOrEmpty(href)) + { + return null; + } + int colon = -1; + for (int i = 0; i < href!.Length; i++) + { + if (href[i] == ':') + { + colon = i; + break; + } + } + if (colon <= 0) + { + return null; + } + return href.Substring(0, colon).ToLowerInvariant(); + } + + /// + /// Requires a non-empty, in-bounds href and reports a diagnostic when + /// it is missing or too long. + /// + protected bool RequireHref( + WotAffordanceForm form, WotBindingPlanContext context, ICollection diagnostics, out string href) + { + href = form.Href ?? string.Empty; + if (string.IsNullOrEmpty(href)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingHref, + "The form has no href.", form.Pointer("href"))); + return false; + } + if (href.Length > context.Bounds.MaxUriLength) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.BoundsExceeded, + $"The href exceeds the maximum length of {context.Bounds.MaxUriLength}.", + form.Pointer("href"))); + return false; + } + return true; + } + + /// + /// Yields the (op token, capability) pairs the binder supports for the + /// form, reporting a diagnostic for each incompatible or unsupported op. + /// + protected IEnumerable<(string Op, WoTBindingCapabilityEnum Capability)> ResolveOperations( + WotAffordanceForm form, ICollection diagnostics) + { + var seen = new HashSet(); + var results = new List<(string, WoTBindingCapabilityEnum)>(); + foreach (string op in form.Operations) + { + if (!WotOperations.IsCompatible(form.Kind, op)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.IncompatibleOperation, + $"The operation '{op}' is not compatible with a {form.Kind} affordance.", + form.Pointer("op"), op)); + continue; + } + if (!WotOperations.TryMap(op, out WoTBindingCapabilityEnum capability)) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.UnsupportedOperation, + $"The operation '{op}' is not modelled by the registry.", + form.Pointer("op"), op)); + continue; + } + if (!Capability.Supports(capability)) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.UnsupportedOperation, + $"The binding '{Identity.Id}' does not support '{op}'.", + form.Pointer("op"), op)); + continue; + } + // "unobserveproperty" and "unsubscribeevent" are teardown ops for a + // running observe / subscribe; do not emit a duplicate entry. + if (op is "unobserveproperty" or "unsubscribeevent") + { + continue; + } + if (seen.Add(capability)) + { + results.Add((op, capability)); + } + } + return results; + } + + /// Selects a codec for a content type, reporting when none is available. + protected string ResolveCodec( + WotAffordanceForm form, WotBindingPlanContext context, out WotPayloadDescriptor payload) + { + string contentType = string.IsNullOrEmpty(form.ContentType) ? "application/json" : form.ContentType!; + context.Codecs.TrySelect(form.ContentType, out IWotPayloadCodec codec); + payload = new WotPayloadDescriptor(contentType, codec.Id); + return codec.Id; + } + + /// + /// Resolves the form's security scheme references into secret-free + /// credential references, reporting a diagnostic for any scheme not + /// declared in the document's securityDefinitions. + /// + protected ImmutableArray ResolveSecurity( + WotAffordanceForm form, WotBindingPlanContext context, string? endpoint, + ICollection diagnostics) + { + if (form.SecuritySchemes.IsEmpty) + { + return ImmutableArray.Empty; + } + var builder = ImmutableArray.CreateBuilder(); + foreach (string scheme in form.SecuritySchemes) + { + if (context.SecurityDefinitions.TryGetValue(scheme, out WotSecurityDefinition? definition)) + { + builder.Add(WotCredentialReference.FromDefinition( + definition, Identity.BindingUri, endpoint)); + } + else if (!string.Equals(scheme, "nosec_sc", StringComparison.Ordinal)) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.UnknownSecurityScheme, + $"The security scheme '{scheme}' is not declared in securityDefinitions.", + form.Pointer("security"), scheme)); + } + } + return builder.ToImmutable(); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/NugetREADME.md b/src/Opc.Ua.WotCon.Binding/NugetREADME.md new file mode 100644 index 0000000000..2f14b3b96a --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/NugetREADME.md @@ -0,0 +1,24 @@ +# OPC UA WoT Connectivity — Protocol Binding Abstractions + +`OPCFoundation.NetStandard.Opc.Ua.WotCon.Binding` is the dependency-light +abstraction and planner layer for the OPC UA WoT Connectivity 1.1 runtime. + +It defines the stable, replaceable protocol-binder contracts used by the +materialization coordinator: + +* binding identification, version and capability descriptors; +* form validation and compilation into immutable binding plans; +* payload codec selection; +* credential / trust reference lookup (no secrets in Thing Descriptions); +* Prepare / Activate / Deactivate lifecycle; +* read / write / observe / action / event operations; +* structured diagnostics with RFC 6901 JSON Pointers. + +The assembly carries **no transport dependencies**. Planner/validator binders +for HTTP, CoAP, MQTT, Modbus TCP, BACnet, PROFINET, LoRaWAN and OPC UA ship +here so unsupported runtime protocols can still validate and compile plans. +Concrete executors live in the optional focused +`Opc.Ua.WotCon.Binding.Http` / `.Mqtt` / `.Modbus` / `.OpcUa` packages. + +See `docs/WoTProtocolBindings.md` for the developer guide and a sample custom +binder. diff --git a/src/Opc.Ua.WotCon.Binding/Opc.Ua.WotCon.Binding.csproj b/src/Opc.Ua.WotCon.Binding/Opc.Ua.WotCon.Binding.csproj new file mode 100644 index 0000000000..7965daf40e --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Opc.Ua.WotCon.Binding.csproj @@ -0,0 +1,32 @@ + + + $(AssemblyPrefix).WotCon.Binding + $(LibTargetFrameworks) + $(PackagePrefix).Opc.Ua.WotCon.Binding + Opc.Ua.WotCon.Binding + $(NoWarn);CS1591 + enable + OPC UA WoT Connectivity protocol-binding abstractions and planners (dependency-light) + true + NugetREADME.md + true + true + + + + + + + + + + + $(PackageId).Debug + + + + + + + + diff --git a/src/Opc.Ua.WotCon.Binding/OpcUaWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Binding/OpcUaWotBindingBuilderExtensions.cs new file mode 100644 index 0000000000..507388d248 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/OpcUaWotBindingBuilderExtensions.cs @@ -0,0 +1,130 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.DependencyInjection.Extensions; +using Opc.Ua; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that register replaceable WoT + /// protocol binders and executors and wire them into the WoT Connectivity 1.1 + /// materialization coordinator. Binders are injected independently and are + /// selected by pinned identification rules; concrete executors are registered + /// separately so a protocol can be validated without being executable and can + /// be upgraded to executable once its executor is present. Executor packages + /// (Opc.Ua.WotCon.Binding.Http / .Mqtt / .Modbus / + /// .OpcUa) call these extensions from their own registration helpers. + /// + public static class OpcUaWotBindingBuilderExtensions + { + /// + /// Registers the eight shipped planner / validator binders (HTTP, CoAP, + /// MQTT, Modbus TCP, BACnet, PROFINET, LoRaWAN and OPC UA) and replaces the + /// binder registry with the aggregating + /// . Without any executor these + /// binders validate and compile plans but materialize non-executable nodes. + /// + public static IOpcUaBuilder AddWotProtocolBinders(this IOpcUaBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + foreach (IWotProtocolBinder binder in WotBuiltInBinders.CreateAll()) + { + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton(binder)); + } + EnsureRegistry(builder.Services); + return builder; + } + + /// Registers a single, custom protocol binder. + public static IOpcUaBuilder AddWotBinder(this IOpcUaBuilder builder, IWotProtocolBinder binder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (binder is null) + { + throw new ArgumentNullException(nameof(binder)); + } + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(binder)); + EnsureRegistry(builder.Services); + return builder; + } + + /// Registers a runtime executor for a binder identity. + public static IOpcUaBuilder AddWotBindingExecutor( + this IOpcUaBuilder builder, IWotBindingExecutor executor) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (executor is null) + { + throw new ArgumentNullException(nameof(executor)); + } + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton(executor)); + EnsureRegistry(builder.Services); + return builder; + } + + /// Registers the credential provider used to resolve secret-free references. + public static IOpcUaBuilder AddWotCredentialProvider( + this IOpcUaBuilder builder, IWotCredentialProvider provider) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (provider is null) + { + throw new ArgumentNullException(nameof(provider)); + } + builder.Services.AddSingleton(provider); + return builder; + } + + private static void EnsureRegistry(IServiceCollection services) + { + services.AddSingleton(sp => new WotProtocolBinderRegistry( + sp.GetServices(), + sp.GetServices(), + sp.GetService(), + sp.GetService())); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/BacnetBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/BacnetBindingPlanner.cs new file mode 100644 index 0000000000..d4ab876057 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/BacnetBindingPlanner.cs @@ -0,0 +1,143 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The W3C WoT BACnet binding planner (Editor's Draft). It validates the + /// bacv: vocabulary (objectType, instanceNumber, + /// propertyIdentifier, optional usePriority) at the schema / + /// document level and compiles immutable object-reference metadata. This build + /// performs planning only; the binding is reported as non-executable. + /// + public sealed class BacnetBindingPlanner : WotProtocolBinderBase + { + /// The BACnet binding vocabulary URI. + public const string BindingUri = "https://www.w3.org/2019/wot/bacnet#"; + + private static readonly string[] s_schemes = { "bacnet" }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.bacnet", "1.0-ed", BindingUri, "W3C WoT BACnet Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "W3C WoT BACnet Binding (Editor's Draft)", + WotBindingSources.Bacnet, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty + }, + new[] { "application/json" }, + isExecutable: false); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "bacv:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!form.TryGetString("bacv:objectType", out string objectType)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "A BACnet form requires bacv:objectType.", + form.Pointer("bacv:objectType"), "bacv:objectType")); + } + if (!form.TryGetInt32("bacv:instanceNumber", out int instanceNumber) || instanceNumber < 0) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "A BACnet form requires a non-negative bacv:instanceNumber.", + form.Pointer("bacv:instanceNumber"), "bacv:instanceNumber")); + } + if (!form.TryGetString("bacv:propertyIdentifier", out string propertyId)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "A BACnet form requires bacv:propertyIdentifier.", + form.Pointer("bacv:propertyIdentifier"), "bacv:propertyIdentifier")); + } + if (form.TryGetInt32("bacv:usePriority", out int priority) && (priority is < 1 or > 16)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "bacv:usePriority must be between 1 and 16.", + form.Pointer("bacv:usePriority"), "bacv:usePriority")); + } + + foreach (WotBindingDiagnostic diagnostic in diagnostics) + { + if (diagnostic.IsError) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + } + + var metadata = ImmutableDictionary.Empty + .Add("objectType", objectType) + .Add("instanceNumber", instanceNumber.ToString(CultureInfo.InvariantCulture)) + .Add("propertyIdentifier", propertyId); + var addressing = new WotAddressingDescriptor( + $"{objectType}:{instanceNumber.ToString(CultureInfo.InvariantCulture)}:{propertyId}", metadata); + WotEndpointDescriptor endpoint = MakeEndpointOrSynthetic(form.Href, "bacnet"); + ResolveCodec(form, context, out WotPayloadDescriptor payload); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, capability.ToString()); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + ImmutableArray.Empty, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/CoapBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/CoapBindingPlanner.cs new file mode 100644 index 0000000000..ba8ad8a3cd --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/CoapBindingPlanner.cs @@ -0,0 +1,154 @@ +/* ======================================================================== + * 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.Collections.Immutable; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The W3C WoT CoAP binding planner (Editor's Draft). It validates the + /// coap / coaps href scheme and the cov: vocabulary + /// (method, observe, contentFormat, accept) and + /// compiles the form into immutable metadata. This build ships the CoAP + /// planner only; the binding is reported as non-executable. + /// + public sealed class CoapBindingPlanner : WotProtocolBinderBase + { + /// The CoAP binding vocabulary URI. + public const string BindingUri = "http://www.w3.org/2019/wot/coap#"; + + private static readonly string[] s_schemes = { "coap", "coaps" }; + private static readonly HashSet s_methods = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "GET", "PUT", "POST", "DELETE", "FETCH", "PATCH", "iPATCH" + }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.coap", "1.0-ed", BindingUri, "W3C WoT CoAP Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "W3C WoT CoAP Binding (Editor's Draft)", + WotBindingSources.Coap, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty, + WoTBindingCapabilityEnum.InvokeAction, + WoTBindingCapabilityEnum.SubscribeEvent, + WoTBindingCapabilityEnum.UnsubscribeEvent + }, + new[] { "application/json", "application/cbor", "text/plain" }, + isExecutable: false); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "cov:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href)) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + if (!TryParseUri(href, out Uri uri) || + (!string.Equals(uri.Scheme, "coap", StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, "coaps", StringComparison.OrdinalIgnoreCase))) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid absolute coap(s) URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string? methodOverride = null; + if (form.TryGetString("cov:method", out string method)) + { + if (!s_methods.Contains(method)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + $"'{method}' is not a valid CoAP method.", form.Pointer("cov:method"), "cov:method")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + methodOverride = method; + } + if (form.FormElement.TryGetProperty("cov:contentFormat", out System.Text.Json.JsonElement cf) && + cf.ValueKind == System.Text.Json.JsonValueKind.Number && !cf.TryGetInt32(out int _)) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.InvalidFieldValue, + "cov:contentFormat should be an integer Content-Format code.", + form.Pointer("cov:contentFormat"), "cov:contentFormat")); + } + + ResolveCodec(form, context, out WotPayloadDescriptor payload); + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor(uri.AbsoluteUri); + ImmutableArray security = + ResolveSecurity(form, context, uri.GetLeftPart(UriPartial.Authority), diagnostics); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + string coapMethod = methodOverride ?? DefaultMethod(capability); + var operation = new WotOperationDescriptor(capability, op, coapMethod); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, security, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private static string DefaultMethod(WoTBindingCapabilityEnum operation) + { + return operation switch + { + WoTBindingCapabilityEnum.WriteProperty => "PUT", + WoTBindingCapabilityEnum.InvokeAction => "POST", + _ => "GET" + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/HttpBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/HttpBindingPlanner.cs new file mode 100644 index 0000000000..61a633af88 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/HttpBindingPlanner.cs @@ -0,0 +1,160 @@ +/* ======================================================================== + * 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.Collections.Immutable; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The W3C WoT HTTP binding planner. It validates the http / https + /// href scheme and the normative htv: vocabulary of TD 1.1, checks + /// op compatibility, content type and required fields, and compiles the + /// form into immutable endpoint / addressing / operation / payload metadata. + /// It is executable when the HTTP executor is registered. + /// + public sealed class HttpBindingPlanner : WotProtocolBinderBase + { + /// The HTTP binding vocabulary URI. + public const string BindingUri = "http://www.w3.org/2011/http#"; + + private static readonly string[] s_schemes = { "http", "https" }; + private static readonly HashSet s_methods = new HashSet(StringComparer.Ordinal) + { + "GET", "PUT", "POST", "DELETE", "PATCH", "HEAD", "OPTIONS" + }; + private static readonly HashSet s_subprotocols = new HashSet(StringComparer.Ordinal) + { + "longpoll", "sse", "websub" + }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.http", "1.1", BindingUri, "W3C WoT HTTP Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "W3C WoT HTTP Binding (TD 1.1)", + WotBindingSources.Http, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty, + WoTBindingCapabilityEnum.InvokeAction, + WoTBindingCapabilityEnum.SubscribeEvent, + WoTBindingCapabilityEnum.UnsubscribeEvent + }, + new[] { "application/json", "text/plain", "application/octet-stream" }, + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "htv:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href)) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + if (!TryParseUri(href, out Uri uri) || + (!string.Equals(uri.Scheme, "http", StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase))) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid absolute http(s) URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string? methodOverride = null; + if (form.TryGetString("htv:methodName", out string method)) + { + if (!s_methods.Contains(method)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + $"'{method}' is not a valid HTTP method.", form.Pointer("htv:methodName"), "htv:methodName")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + methodOverride = method; + } + + if (!string.IsNullOrEmpty(form.Subprotocol) && !s_subprotocols.Contains(form.Subprotocol!)) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.InvalidFieldValue, + $"The subprotocol '{form.Subprotocol}' is not a recognized HTTP subprotocol.", + form.Pointer("subprotocol"), "subprotocol")); + } + + ResolveCodec(form, context, out WotPayloadDescriptor payload); + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor(uri.AbsoluteUri); + ImmutableArray security = + ResolveSecurity(form, context, uri.GetLeftPart(UriPartial.Authority), diagnostics); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + string httpMethod = methodOverride ?? DefaultMethod(capability); + var operation = new WotOperationDescriptor( + capability, op, httpMethod, + ImmutableDictionary.Empty.Add("subprotocol", form.Subprotocol ?? string.Empty)); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, security, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private static string DefaultMethod(WoTBindingCapabilityEnum operation) + { + return operation switch + { + WoTBindingCapabilityEnum.WriteProperty => "PUT", + WoTBindingCapabilityEnum.InvokeAction => "POST", + _ => "GET" + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/LoRaWanBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/LoRaWanBindingPlanner.cs new file mode 100644 index 0000000000..79ec18eefa --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/LoRaWanBindingPlanner.cs @@ -0,0 +1,153 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The LoRaWAN binding planner (unofficial draft). It validates the + /// lorawan: vocabulary (DevEUI, optional fPort) at the + /// schema / document level and compiles immutable device addressing metadata. + /// This build performs planning only; the binding is reported as + /// non-executable. + /// + public sealed class LoRaWanBindingPlanner : WotProtocolBinderBase + { + /// The LoRaWAN binding vocabulary URI. + public const string BindingUri = "https://www.w3.org/2019/wot/lorawan#"; + + private static readonly string[] s_schemes = { "lorawan" }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.lorawan", "0.1-draft", BindingUri, "WoT LoRaWAN Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "WoT LoRaWAN Binding (unofficial draft)", + WotBindingSources.LoRaWan, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty + }, + new[] { "application/octet-stream", "application/json" }, + isExecutable: false); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "lorawan:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!form.TryGetString("lorawan:DevEUI", out string devEui) && + !form.TryGetString("lorawan:devEUI", out devEui)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "A LoRaWAN form requires lorawan:DevEUI.", + form.Pointer("lorawan:DevEUI"), "lorawan:DevEUI")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + if (!IsHex(devEui, 16)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "lorawan:DevEUI must be a 16-character hexadecimal identifier.", + form.Pointer("lorawan:DevEUI"), "lorawan:DevEUI")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + int fPort = 1; + if (form.TryGetInt32("lorawan:fPort", out int parsedPort)) + { + if (parsedPort is < 1 or > 223) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "lorawan:fPort must be between 1 and 223.", + form.Pointer("lorawan:fPort"), "lorawan:fPort")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + fPort = parsedPort; + } + + var metadata = ImmutableDictionary.Empty + .Add("devEui", devEui) + .Add("fPort", fPort.ToString(CultureInfo.InvariantCulture)); + var addressing = new WotAddressingDescriptor( + $"{devEui}/{fPort.ToString(CultureInfo.InvariantCulture)}", metadata); + WotEndpointDescriptor endpoint = MakeEndpointOrSynthetic(form.Href, "lorawan"); + ResolveCodec(form, context, out WotPayloadDescriptor payload); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, capability.ToString()); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + ImmutableArray.Empty, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private static bool IsHex(string value, int length) + { + if (value is null || value.Length != length) + { + return false; + } + foreach (char c in value) + { + bool hex = (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F'); + if (!hex) + { + return false; + } + } + return true; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/ModbusBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/ModbusBindingPlanner.cs new file mode 100644 index 0000000000..b2bb748e34 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/ModbusBindingPlanner.cs @@ -0,0 +1,418 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The W3C WoT Modbus binding planner (Editor's Draft). It validates the + /// modbus+tcp href scheme and the modv: vocabulary + /// (entity, function, address, quantity, + /// unitID, type), enforces read-only entity and quantity bounds, + /// checks op compatibility, and compiles the form into immutable Modbus + /// register addressing metadata. It is executable when the Modbus executor is + /// registered. + /// + public sealed class ModbusBindingPlanner : WotProtocolBinderBase + { + /// The Modbus binding vocabulary URI. + public const string BindingUri = "https://www.w3.org/2019/wot/modbus#"; + + /// The maximum addressable 16-bit Modbus register / bit address. + private const int MaxAddress = 65535; + + private static readonly string[] s_schemes = { "modbus+tcp", "modbus" }; + private static readonly HashSet s_readOnlyEntities = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "discreteinput", "inputregister" + }; + private static readonly HashSet s_entities = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "coil", "discreteinput", "holdingregister", "inputregister" + }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.modbus", "1.0-ed", BindingUri, "W3C WoT Modbus Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "W3C WoT Modbus TCP Binding (Editor's Draft)", + WotBindingSources.Modbus, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty + }, + new[] { "application/octet-stream", "application/json" }, + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "modv:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href)) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + if (!TryParseUri(href, out Uri uri) || + (!string.Equals(uri.Scheme, "modbus+tcp", StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, "modbus", StringComparison.OrdinalIgnoreCase))) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid absolute modbus+tcp URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string? entity = form.TryGetString("modv:entity", out string entityValue) ? entityValue : null; + if (entity is not null && !s_entities.Contains(entity)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + $"'{entity}' is not a valid Modbus entity.", form.Pointer("modv:entity"), "modv:entity")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + // Resolve modv:function (string mnemonic or numeric code) to a canonical + // function code, its entity and its read / write direction. A function is + // optional, but when present it must be one of the exactly mapped codes + // 1, 2, 3, 4, 5, 6, 15 or 16. + bool functionPresent = form.FormElement.ValueKind == System.Text.Json.JsonValueKind.Object && + form.FormElement.TryGetProperty("modv:function", out _); + ModbusFunctionInfo? function = null; + if (functionPresent) + { + if (!TryResolveFunction(form, out ModbusFunctionInfo resolved)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "modv:function must be one of the Modbus function codes 1, 2, 3, 4, 5, 6, 15 or 16 " + + "(or their canonical mnemonics).", + form.Pointer("modv:function"), "modv:function")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + function = resolved; + } + + if (entity is null && function is null) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "A Modbus form requires modv:entity or modv:function.", + form.Pointer("modv:entity"), "modv:entity")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + // A form that declares both must be internally consistent: the function's + // entity has to match the declared entity. + if (entity is not null && function is not null && + !string.Equals(entity, function.Value.Entity, StringComparison.OrdinalIgnoreCase)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.ConflictingFields, + $"modv:function '{function.Value.Mnemonic}' operates on '{function.Value.Entity}' " + + $"which conflicts with modv:entity '{entity}'.", + form.Pointer("modv:function"), "modv:function")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string effectiveEntity = entity ?? function!.Value.Entity; + + if (!form.TryGetInt32("modv:address", out int address) || address is < 0 or > MaxAddress) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + $"A Modbus form requires a modv:address between 0 and {MaxAddress}.", + form.Pointer("modv:address"), "modv:address")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + int quantity = form.TryGetInt32("modv:quantity", out int parsedQuantity) ? parsedQuantity : 1; + if (quantity < 1) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "modv:quantity must be at least 1.", form.Pointer("modv:quantity"), "modv:quantity")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + bool bitEntity = + string.Equals(effectiveEntity, "coil", StringComparison.OrdinalIgnoreCase) || + string.Equals(effectiveEntity, "discreteInput", StringComparison.OrdinalIgnoreCase); + int maxQuantity = bitEntity ? context.Bounds.MaxCoilQuantity : context.Bounds.MaxRegisterQuantity; + if (quantity > maxQuantity) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.BoundsExceeded, + $"modv:quantity {quantity} exceeds the maximum of {maxQuantity}.", + form.Pointer("modv:quantity"), "modv:quantity")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + // The addressed range must not run past the 16-bit Modbus address space. + if (address + quantity - 1 > MaxAddress) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.BoundsExceeded, + $"The Modbus range starting at {address} for {quantity} items exceeds the " + + $"maximum address {MaxAddress}.", + form.Pointer("modv:quantity"), "modv:quantity")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + int unitId = form.TryGetInt32("modv:unitID", out int parsedUnit) ? parsedUnit : ParseUnitFromPath(uri); + if (unitId is < 0 or > 255) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "modv:unitID must be between 0 and 255.", form.Pointer("modv:unitID"), "modv:unitID")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string dataType = form.TryGetString("modv:type", out string type) ? type : "uint16"; + bool msbFirst = !form.TryGetBoolean("modv:mostSignificantByte", out bool msb) || msb; + bool mswFirst = !form.TryGetBoolean("modv:mostSignificantWord", out bool msw) || msw; + + ImmutableDictionary address4 = ImmutableDictionary.Empty + .Add("entity", effectiveEntity) + .Add("address", address.ToString(CultureInfo.InvariantCulture)) + .Add("quantity", quantity.ToString(CultureInfo.InvariantCulture)) + .Add("unitId", unitId.ToString(CultureInfo.InvariantCulture)); + if (function is not null) + { + address4 = address4 + .Add("function", function.Value.Mnemonic) + .Add("functionCode", function.Value.Code.ToString(CultureInfo.InvariantCulture)); + } + + var payloadMetadata = ImmutableDictionary.Empty + .Add("type", dataType) + .Add("mostSignificantByte", msbFirst ? "true" : "false") + .Add("mostSignificantWord", mswFirst ? "true" : "false"); + var payload = new WotPayloadDescriptor( + string.IsNullOrEmpty(form.ContentType) ? "application/octet-stream" : form.ContentType!, + OctetStreamWotPayloadCodec.Instance.Id, payloadMetadata); + + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor( + $"{effectiveEntity}:{address}:{quantity}@{unitId}", address4); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + bool isWriteOp = capability == WoTBindingCapabilityEnum.WriteProperty; + if (isWriteOp && s_readOnlyEntities.Contains(effectiveEntity)) + { + // A read-only entity (discrete input / input register) commonly + // carries the default read+write property ops. Drop only the + // write op with a warning so the valid read binding is still + // compiled; an error here would set HasErrors and cause the + // whole form (read included) to be dropped as unsupported. + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.ConflictingFields, + $"The Modbus entity '{effectiveEntity}' is read-only; the write operation is not " + + "executable and was dropped while the read binding is preserved.", + form.Pointer("modv:entity"), "modv:entity")); + continue; + } + // Reject op / function direction mismatches: an explicit write + // function cannot serve a read / observe op and vice versa. The + // offending op is dropped (rejected) while any compatible op on the + // same form is preserved. + if (function is not null && function.Value.IsWrite != isWriteOp) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.ConflictingFields, + $"modv:function '{function.Value.Mnemonic}' is a " + + (function.Value.IsWrite ? "write" : "read") + + $" function and cannot serve the '{op}' operation; the operation was dropped.", + form.Pointer("modv:function"), "modv:function")); + continue; + } + string method = function is not null + ? function.Value.Mnemonic + : ModbusFunction(capability, effectiveEntity, quantity); + var operation = new WotOperationDescriptor(capability, op, method); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + ImmutableArray.Empty, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private static int ParseUnitFromPath(Uri uri) + { + string path = uri.AbsolutePath.Trim('/'); + return int.TryParse(path, NumberStyles.Integer, CultureInfo.InvariantCulture, out int unit) ? unit : 1; + } + + private static string ModbusFunction(WoTBindingCapabilityEnum operation, string? entity, int quantity) + { + bool coil = string.Equals(entity, "coil", StringComparison.OrdinalIgnoreCase); + bool discrete = string.Equals(entity, "discreteInput", StringComparison.OrdinalIgnoreCase); + bool input = string.Equals(entity, "inputRegister", StringComparison.OrdinalIgnoreCase); + if (operation == WoTBindingCapabilityEnum.WriteProperty) + { + return coil + ? (quantity > 1 ? "writeMultipleCoils" : "writeSingleCoil") + : (quantity > 1 ? "writeMultipleHoldingRegisters" : "writeSingleHoldingRegister"); + } + if (coil) + { + return "readCoil"; + } + if (discrete) + { + return "readDiscreteInput"; + } + if (input) + { + return "readInputRegister"; + } + return "readHoldingRegisters"; + } + + /// The canonical entity, direction and code for a Modbus function. + private readonly struct ModbusFunctionInfo + { + public ModbusFunctionInfo(int code, string entity, bool isWrite, string mnemonic) + { + Code = code; + Entity = entity; + IsWrite = isWrite; + Mnemonic = mnemonic; + } + + public int Code { get; } + + public string Entity { get; } + + public bool IsWrite { get; } + + public string Mnemonic { get; } + } + + /// + /// Resolves the modv:function term (a string mnemonic or a numeric + /// code) to one of the exactly mapped Modbus function codes 1, 2, 3, 4, 5, + /// 6, 15 or 16. Returns false when the term is present but not a + /// recognized function. + /// + private static bool TryResolveFunction(WotAffordanceForm form, out ModbusFunctionInfo info) + { + if (form.TryGetString("modv:function", out string text) && !string.IsNullOrEmpty(text)) + { + switch (text.Trim().ToLowerInvariant()) + { + case "readcoil": + case "readcoils": + info = Function(1); + return true; + case "readdiscreteinput": + case "readdiscreteinputs": + info = Function(2); + return true; + case "readholdingregister": + case "readholdingregisters": + info = Function(3); + return true; + case "readinputregister": + case "readinputregisters": + info = Function(4); + return true; + case "writesinglecoil": + info = Function(5); + return true; + case "writesingleholdingregister": + case "writesingleregister": + info = Function(6); + return true; + case "writemultiplecoils": + info = Function(15); + return true; + case "writemultipleholdingregisters": + case "writemultipleregisters": + info = Function(16); + return true; + default: + if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out int parsed) && + IsMappedCode(parsed)) + { + info = Function(parsed); + return true; + } + break; + } + } + else if (form.TryGetInt32("modv:function", out int code) && IsMappedCode(code)) + { + info = Function(code); + return true; + } + info = default; + return false; + } + + private static bool IsMappedCode(int code) + => code is 1 or 2 or 3 or 4 or 5 or 6 or 15 or 16; + + private static ModbusFunctionInfo Function(int code) + { + return code switch + { + 1 => new ModbusFunctionInfo(1, "coil", false, "readCoil"), + 2 => new ModbusFunctionInfo(2, "discreteInput", false, "readDiscreteInput"), + 3 => new ModbusFunctionInfo(3, "holdingRegister", false, "readHoldingRegisters"), + 4 => new ModbusFunctionInfo(4, "inputRegister", false, "readInputRegister"), + 5 => new ModbusFunctionInfo(5, "coil", true, "writeSingleCoil"), + 6 => new ModbusFunctionInfo(6, "holdingRegister", true, "writeSingleHoldingRegister"), + 15 => new ModbusFunctionInfo(15, "coil", true, "writeMultipleCoils"), + _ => new ModbusFunctionInfo(16, "holdingRegister", true, "writeMultipleHoldingRegisters") + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/MqttBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/MqttBindingPlanner.cs new file mode 100644 index 0000000000..a769501131 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/MqttBindingPlanner.cs @@ -0,0 +1,185 @@ +/* ======================================================================== + * 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.Collections.Immutable; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The W3C WoT MQTT binding planner (Editor's Draft). It validates the + /// mqtt / mqtts href scheme and the mqv: vocabulary + /// (controlPacket, topic, qos, retain), checks + /// op compatibility, content type, QoS and topic bounds, and compiles + /// the form into immutable publish / subscribe metadata. It is executable when + /// the MQTT executor is registered. + /// + public sealed class MqttBindingPlanner : WotProtocolBinderBase + { + /// The MQTT binding vocabulary URI. + public const string BindingUri = "https://www.w3.org/2019/wot/mqtt"; + + private static readonly string[] s_schemes = { "mqtt", "mqtts" }; + private static readonly HashSet s_controlPackets = new HashSet(StringComparer.OrdinalIgnoreCase) + { + "connect", "publish", "subscribe", "unsubscribe", "disconnect" + }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.mqtt", "1.0-ed", BindingUri, "W3C WoT MQTT Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "W3C WoT MQTT Binding (Editor's Draft)", + WotBindingSources.Mqtt, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty, + WoTBindingCapabilityEnum.InvokeAction, + WoTBindingCapabilityEnum.SubscribeEvent, + WoTBindingCapabilityEnum.UnsubscribeEvent + }, + new[] { "application/json", "text/plain", "application/octet-stream" }, + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "mqv:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href)) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + if (!TryParseUri(href, out Uri uri) || + (!string.Equals(uri.Scheme, "mqtt", StringComparison.OrdinalIgnoreCase) && + !string.Equals(uri.Scheme, "mqtts", StringComparison.OrdinalIgnoreCase))) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid absolute mqtt(s) URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string topic = form.TryGetString("mqv:topic", out string explicitTopic) + ? explicitTopic + : uri.AbsolutePath.TrimStart('/'); + if (string.IsNullOrEmpty(topic)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "No MQTT topic could be resolved from mqv:topic or the href path.", + form.Pointer("mqv:topic"), "mqv:topic")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + if (topic.Length > context.Bounds.MaxTopicLength) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.BoundsExceeded, + $"The MQTT topic exceeds the maximum length of {context.Bounds.MaxTopicLength}.", + form.Pointer("mqv:topic"), "mqv:topic")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + int qos = 0; + if (form.TryGetInt32("mqv:qos", out int parsedQos)) + { + if (parsedQos is < 0 or > 2) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidFieldValue, + "mqv:qos must be 0, 1 or 2.", form.Pointer("mqv:qos"), "mqv:qos")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + qos = parsedQos; + } + + bool retain = form.TryGetBoolean("mqv:retain", out bool retainValue) && retainValue; + + string? controlPacket = null; + if (form.TryGetString("mqv:controlPacket", out string packet)) + { + if (!s_controlPackets.Contains(packet)) + { + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.UnknownVocabularyTerm, + $"'{packet}' is not a recognized MQTT control packet.", + form.Pointer("mqv:controlPacket"), "mqv:controlPacket")); + } + controlPacket = packet; + } + + ResolveCodec(form, context, out WotPayloadDescriptor payload); + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor(topic, + ImmutableDictionary.Empty + .Add("qos", qos.ToString(System.Globalization.CultureInfo.InvariantCulture)) + .Add("retain", retain ? "true" : "false")); + ImmutableArray security = + ResolveSecurity(form, context, uri.GetLeftPart(UriPartial.Authority), diagnostics); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + string method = controlPacket ?? DefaultControlPacket(capability); + var operation = new WotOperationDescriptor(capability, op, method); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, security, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private static string DefaultControlPacket(WoTBindingCapabilityEnum operation) + { + return operation switch + { + WoTBindingCapabilityEnum.WriteProperty => "publish", + WoTBindingCapabilityEnum.InvokeAction => "publish", + _ => "subscribe" + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/OpcUaBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/OpcUaBindingPlanner.cs new file mode 100644 index 0000000000..23e2f8eb1b --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/OpcUaBindingPlanner.cs @@ -0,0 +1,228 @@ +/* ======================================================================== + * 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.Collections.Immutable; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The OPC UA WoT Connectivity binding planner (OPC 10101). It validates the + /// portable uav:id / opc.tcp href, the uav:componentOf + /// containment reference and the uav:mapToNodeId / uav:mapToType + /// / uav:mapByFieldPath mapping terms, checks op compatibility, + /// and compiles the form into immutable endpoint and NodeId addressing + /// metadata. It is executable when the OPC UA executor is registered. + /// + public sealed class OpcUaBindingPlanner : WotProtocolBinderBase + { + /// The OPC UA WoT binding vocabulary URI. + public const string BindingUri = "http://opcfoundation.org/UA/WoT-Binding/"; + + private static readonly string[] s_schemes = { "opc.tcp", "opc.https", "opc.wss" }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("opc.opcua", "10101", BindingUri, "OPC UA WoT Connectivity Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "OPC UA WoT Connectivity Binding (OPC 10101)", + WotBindingSources.OpcUa, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty, + WoTBindingCapabilityEnum.InvokeAction, + WoTBindingCapabilityEnum.SubscribeEvent, + WoTBindingCapabilityEnum.UnsubscribeEvent + }, + new[] { "application/json", "application/opcua+json", "application/octet-stream" }, + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "uav:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + + WotEndpointDescriptor endpoint; + string? authority = null; + if (!string.IsNullOrEmpty(form.Href) && TryParseUri(form.Href!, out Uri uri)) + { + if (!IsOpcScheme(uri.Scheme)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.UnsupportedScheme, + $"'{uri.Scheme}' is not an OPC UA transport scheme.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + endpoint = MakeEndpoint(uri); + authority = uri.GetLeftPart(UriPartial.Authority); + } + else if (!string.IsNullOrEmpty(context.BaseUri) && TryParseUri(context.BaseUri!, out Uri baseUri) && + IsOpcScheme(baseUri.Scheme)) + { + endpoint = MakeEndpoint(baseUri); + authority = baseUri.GetLeftPart(UriPartial.Authority); + } + else + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "An OPC UA form requires an opc.tcp href or a Thing base opc.tcp endpoint.", + form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string? nodeId = ResolveNodeId(form); + if (string.IsNullOrEmpty(nodeId)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + "An OPC UA form requires uav:id or a NodeId in the href path.", + form.Pointer("uav:id"), "uav:id")); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + ImmutableDictionary metadata = ImmutableDictionary.Empty + .Add("nodeId", nodeId!); + metadata = AddIfPresent(form, "uav:componentOf", "componentOf", metadata); + metadata = AddIfPresent(form, "uav:mapToNodeId", "mapToNodeId", metadata); + metadata = AddIfPresent(form, "uav:mapToType", "mapToType", metadata); + metadata = AddIfPresent(form, "uav:mapByFieldPath", "mapByFieldPath", metadata); + if (form.Kind == WotAffordanceKind.Event && + form.TryGetStringArray("uav:eventFields", out ImmutableArray eventFields)) + { + // '|' joins the binding-authored select-clause browse paths; a + // browse name legally contains '/' (nested paths) but never '|'. + metadata = metadata.Add("eventFields", string.Join("|", eventFields)); + } + + ResolveCodec(form, context, out WotPayloadDescriptor payload); + var addressing = new WotAddressingDescriptor(nodeId!, metadata); + ImmutableArray security = ResolveSecurity(form, context, authority, diagnostics); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, OpcUaService(capability)); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, security, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private static bool IsOpcScheme(string scheme) + { + foreach (string handled in s_schemes) + { + if (string.Equals(scheme, handled, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + private static string? ResolveNodeId(WotAffordanceForm form) + { + if (form.TryGetString("uav:id", out string id) && !string.IsNullOrEmpty(id)) + { + return id; + } + if (!string.IsNullOrEmpty(form.Href) && TryParseUri(form.Href!, out Uri uri)) + { + string path = uri.AbsolutePath.Trim('/'); + if (LooksLikeNodeId(path)) + { + return Uri.UnescapeDataString(path); + } + string query = uri.Query.TrimStart('?'); + if (query.StartsWith("id=", StringComparison.OrdinalIgnoreCase)) + { + return Uri.UnescapeDataString(query.Remove(0, 3)); + } + } + return null; + } + + private static bool LooksLikeNodeId(string value) + { + if (string.IsNullOrEmpty(value)) + { + return false; + } + // A textual OPC UA NodeId always carries an identifier assignment + // (for example "i=", "s=", "g=", "b=" or a namespace "ns="). + foreach (char c in value) + { + if (c == '=') + { + return true; + } + } + return false; + } + + private static ImmutableDictionary AddIfPresent( + WotAffordanceForm form, string term, string key, ImmutableDictionary metadata) + { + return form.TryGetString(term, out string value) ? metadata.Add(key, value) : metadata; + } + + private static string OpcUaService(WoTBindingCapabilityEnum operation) + { + return operation switch + { + WoTBindingCapabilityEnum.WriteProperty => "Write", + WoTBindingCapabilityEnum.ObserveProperty => "Subscribe", + WoTBindingCapabilityEnum.InvokeAction => "Call", + WoTBindingCapabilityEnum.SubscribeEvent => "EventSubscribe", + WoTBindingCapabilityEnum.UnsubscribeEvent => "EventSubscribe", + _ => "Read" + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/ProfinetBindingPlanner.cs b/src/Opc.Ua.WotCon.Binding/Planners/ProfinetBindingPlanner.cs new file mode 100644 index 0000000000..1edb65f27e --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/ProfinetBindingPlanner.cs @@ -0,0 +1,133 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The PROFINET binding planner (unofficial draft). It validates the + /// pnv: vocabulary (slot, subslot, index, optional + /// api) at the schema / document level and compiles immutable + /// slot / subslot / index addressing metadata. This build performs planning + /// only; the binding is reported as non-executable. + /// + public sealed class ProfinetBindingPlanner : WotProtocolBinderBase + { + /// The PROFINET binding vocabulary URI. + public const string BindingUri = "https://www.w3.org/2019/wot/profinet#"; + + private static readonly string[] s_schemes = { "profinet" }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("w3c.profinet", "0.1-draft", BindingUri, "WoT PROFINET Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "WoT PROFINET Binding (unofficial draft)", + WotBindingSources.Profinet, + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty + }, + new[] { "application/octet-stream" }, + isExecutable: false); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "pnv:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + bool ok = RequirePositive(form, "pnv:slot", diagnostics, out int slot); + ok &= RequirePositive(form, "pnv:subslot", diagnostics, out int subslot); + ok &= RequirePositive(form, "pnv:index", diagnostics, out int index); + if (!ok) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + var metadata = ImmutableDictionary.Empty + .Add("slot", slot.ToString(CultureInfo.InvariantCulture)) + .Add("subslot", subslot.ToString(CultureInfo.InvariantCulture)) + .Add("index", index.ToString(CultureInfo.InvariantCulture)); + if (form.TryGetInt32("pnv:api", out int api)) + { + metadata = metadata.Add("api", api.ToString(CultureInfo.InvariantCulture)); + } + + var addressing = new WotAddressingDescriptor( + $"slot:{slot.ToString(CultureInfo.InvariantCulture)}/subslot:" + + $"{subslot.ToString(CultureInfo.InvariantCulture)}/index:" + + index.ToString(CultureInfo.InvariantCulture), metadata); + WotEndpointDescriptor endpoint = MakeEndpointOrSynthetic(form.Href, "profinet"); + ResolveCodec(form, context, out WotPayloadDescriptor payload); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, capability.ToString()); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + ImmutableArray.Empty, Capability.IsExecutable)); + } + + if (entries.Count == 0) + { + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + return WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + + private bool RequirePositive( + WotAffordanceForm form, string term, List diagnostics, out int value) + { + if (!form.TryGetInt32(term, out value) || value < 0) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.MissingRequiredField, + $"A PROFINET form requires a non-negative {term}.", form.Pointer(term), term)); + return false; + } + return true; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/WotBindingSources.cs b/src/Opc.Ua.WotCon.Binding/Planners/WotBindingSources.cs new file mode 100644 index 0000000000..587601c432 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/WotBindingSources.cs @@ -0,0 +1,127 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// The version-pinned specification sources every shipped planner is built + /// against. Each source captures the exact document URL, its version / date + /// and its standards maturity so operators can audit precisely which mapping + /// is enforced. The W3C Binding Templates registry is a pilot and is + /// intentionally never reported as Current; drafts expose their Editor's Draft + /// maturity and the OPC UA binding exposes its OPC specification maturity. + /// + public static class WotBindingSources + { + /// The date this catalogue of sources was last pinned. + public const string Retrieved = "2026-07-21"; + + /// + /// The W3C WoT Thing Description 1.1 Recommendation, which contains the + /// normative HTTP protocol binding (htv: terms and default methods). + /// + public static WotBindingSource Http { get; } = new WotBindingSource( + "https://www.w3.org/TR/wot-thing-description11/", + "1.1", + WotBindingMaturity.Recommendation, + commit: "REC-wot-thing-description11-20231205", + retrieved: Retrieved, + note: "Normative HTTP mapping in TD 1.1; htv: terms per http://www.w3.org/2011/http#. " + + "The standalone W3C WoT Binding Templates HTTP note is an Editor's Draft; the " + + "W3C Binding Registry is a pilot and currently empty."); + + /// The W3C WoT Binding Templates CoAP Editor's Draft. + public static WotBindingSource Coap { get; } = new WotBindingSource( + "https://w3c.github.io/wot-binding-templates/bindings/protocols/coap/", + "editors-draft", + WotBindingMaturity.EditorsDraft, + commit: "w3c/wot-binding-templates@main", + retrieved: Retrieved, + note: "cov: terms per the CoAP binding Editor's Draft. Planner-only in this build."); + + /// The W3C WoT Binding Templates MQTT Editor's Draft. + public static WotBindingSource Mqtt { get; } = new WotBindingSource( + "https://w3c.github.io/wot-binding-templates/bindings/protocols/mqtt/", + "editors-draft", + WotBindingMaturity.EditorsDraft, + commit: "w3c/wot-binding-templates@main", + retrieved: Retrieved, + note: "mqv: terms (controlPacket, topic, qos, retain) per the MQTT binding Editor's Draft."); + + /// The W3C WoT Binding Templates Modbus Editor's Draft. + public static WotBindingSource Modbus { get; } = new WotBindingSource( + "https://w3c.github.io/wot-binding-templates/bindings/protocols/modbus/", + "editors-draft", + WotBindingMaturity.EditorsDraft, + commit: "w3c/wot-binding-templates@main", + retrieved: Retrieved, + note: "modv: terms (function, entity, address, quantity, unitID, type) per " + + "https://www.w3.org/2019/wot/modbus# and the Modbus binding Editor's Draft."); + + /// The W3C WoT Binding Templates BACnet Editor's Draft. + public static WotBindingSource Bacnet { get; } = new WotBindingSource( + "https://w3c.github.io/wot-binding-templates/bindings/protocols/bacnet/", + "editors-draft", + WotBindingMaturity.EditorsDraft, + commit: "w3c/wot-binding-templates@main", + retrieved: Retrieved, + note: "bacv: terms per the BACnet binding Editor's Draft. Schema/document-level " + + "planning only; reported as non-executable."); + + /// The W3C WoT Binding Templates PROFINET contribution (unofficial draft). + public static WotBindingSource Profinet { get; } = new WotBindingSource( + "https://w3c.github.io/wot-binding-templates/bindings/protocols/", + "unofficial-draft", + WotBindingMaturity.UnofficialDraft, + commit: "w3c/wot-binding-templates@main", + retrieved: Retrieved, + note: "pnv: terms (slot, subslot, index, api) per the PROFINET contribution. Not yet a " + + "published W3C binding; schema/document-level planning only, reported as non-executable."); + + /// The W3C WoT Binding Templates LoRaWAN contribution (unofficial draft). + public static WotBindingSource LoRaWan { get; } = new WotBindingSource( + "https://w3c.github.io/wot-binding-templates/bindings/protocols/", + "unofficial-draft", + WotBindingMaturity.UnofficialDraft, + commit: "w3c/wot-binding-templates@main", + retrieved: Retrieved, + note: "lorawan: terms (DevEUI, fPort) per the LoRaWAN contribution. Not yet a published " + + "W3C binding; schema/document-level planning only, reported as non-executable."); + + /// The OPC UA WoT Connectivity binding (OPC 10101). + public static WotBindingSource OpcUa { get; } = new WotBindingSource( + "https://reference.opcfoundation.org/WoT/v100/docs/", + "OPC 10101 1.00", + WotBindingMaturity.OpcSpecification, + commit: "OPC-10101-1.00", + retrieved: Retrieved, + note: "uav: terms (id, componentOf, mapToNodeId, mapToType, mapByFieldPath, " + + "eventFields) per the official OPC UA WoT Connectivity binding (OPC 10101)."); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Planners/WotBuiltInBinders.cs b/src/Opc.Ua.WotCon.Binding/Planners/WotBuiltInBinders.cs new file mode 100644 index 0000000000..e1da7c47e5 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Planners/WotBuiltInBinders.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.Collections.Generic; + +namespace Opc.Ua.WotCon.Binding.Planners +{ + /// + /// A factory for the eight planner / validator binders shipped in this + /// dependency-light assembly (HTTP, CoAP, MQTT, Modbus TCP, BACnet, PROFINET, + /// LoRaWAN and OPC UA). Executable binders (HTTP, MQTT, Modbus, OPC UA) become + /// executable only when their optional executor is registered alongside them; + /// the remaining binders are always planner-only. + /// + public static class WotBuiltInBinders + { + /// Creates the full set of shipped planner binders. + public static IReadOnlyList CreateAll() + { + return new IWotProtocolBinder[] + { + new HttpBindingPlanner(), + new CoapBindingPlanner(), + new MqttBindingPlanner(), + new ModbusBindingPlanner(), + new BacnetBindingPlanner(), + new ProfinetBindingPlanner(), + new LoRaWanBindingPlanner(), + new OpcUaBindingPlanner() + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/PollingWotSubscription.cs b/src/Opc.Ua.WotCon.Binding/PollingWotSubscription.cs new file mode 100644 index 0000000000..58755719a7 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/PollingWotSubscription.cs @@ -0,0 +1,148 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// A reusable observe / event subscription that periodically polls a source + /// on a bounded interval and stops cleanly when disposed. It is used by + /// executors whose transports have no native push channel (for example HTTP + /// polling or Modbus polling). + /// + public sealed class PollingWotSubscription : IWotSubscription + { + /// Initializes and starts a new polling subscription. + /// The compiled form being observed. + /// The poll callback, invoked once per interval. + /// The bounded poll interval. + /// + /// An optional handler invoked when a single poll iteration faults with a + /// non-cancellation exception. A transient poll or callback fault is + /// reported here and the loop keeps polling; it never permanently faults + /// the subscription. A null handler silently continues. + /// + public PollingWotSubscription( + WotCompiledForm form, + Func pollAsync, + TimeSpan interval, + Action? onError = null) + { + Form = form ?? throw new ArgumentNullException(nameof(form)); + m_pollAsync = pollAsync ?? throw new ArgumentNullException(nameof(pollAsync)); + m_interval = interval <= TimeSpan.Zero ? TimeSpan.FromSeconds(1) : interval; + m_onError = onError; + m_loop = RunAsync(m_cts.Token); + } + + /// + public WotCompiledForm Form { get; } + + /// + public async ValueTask DisposeAsync() + { + // Dispose the cancellation source in a finally so a faulted loop can + // never leak it. The loop is designed not to fault (transient errors + // are handled per iteration), but awaiting it is still guarded so a + // residual exception is never rethrown from DisposeAsync. + try + { + m_cts.Cancel(); + try + { + await m_loop.ConfigureAwait(false); + } + catch (OperationCanceledException) + { + // Expected on cancellation. + } + } + finally + { + m_cts.Dispose(); + } + } + + private async Task RunAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await m_pollAsync(cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cooperative cancellation is never swallowed as an error; + // stop the loop cleanly. + return; + } + catch (Exception ex) + { + // A transient poll or callback fault must not permanently + // fault the loop: report it and keep polling on the next + // interval. This includes a spurious OperationCanceledException + // that is not our own cancellation (for example a transport + // timeout surfaced as a cancellation). + ReportError(ex); + } + + try + { + await Task.Delay(m_interval, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + } + } + + private void ReportError(Exception ex) + { + try + { + m_onError?.Invoke(ex); + } + catch + { + // An error handler must never take down the poll loop. + } + } + + private readonly Func m_pollAsync; + private readonly TimeSpan m_interval; + private readonly Action? m_onError; + private readonly CancellationTokenSource m_cts = new CancellationTokenSource(); + private readonly Task m_loop; + } +} diff --git a/src/Opc.Ua.WotCon.Binding/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Binding/Properties/AssemblyInfo.cs new file mode 100644 index 0000000000..7798c9bd57 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Properties/AssemblyInfo.cs @@ -0,0 +1,32 @@ +/* ======================================================================== + * 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; + +[assembly: CLSCompliant(false)] diff --git a/src/Opc.Ua.WotCon.Binding/Samples/MemoryWotBinding.cs b/src/Opc.Ua.WotCon.Binding/Samples/MemoryWotBinding.cs new file mode 100644 index 0000000000..fc54097427 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/Samples/MemoryWotBinding.cs @@ -0,0 +1,219 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding.Samples +{ + /// + /// A worked sample showing how a third party contributes a replaceable + /// protocol binder as pure code-behind. The fictitious mem protocol + /// binds property affordances to an in-process key/value store, demonstrating + /// the full extension surface: identity, capability, deterministic + /// identification, a planner and an executor with a live channel. Register it + /// with builder.AddWotBinder(new MemoryWotBinder()) and + /// builder.AddWotBindingExecutor(new MemoryWotBindingExecutor(store)). + /// + public sealed class MemoryWotBinder : WotProtocolBinderBase + { + /// The sample binding vocabulary URI. + public const string BindingUri = "urn:example:wot:mem"; + + private static readonly string[] s_schemes = { "mem" }; + + /// + public override WotBindingIdentity Identity { get; } = + new WotBindingIdentity("example.mem", "1.0", BindingUri, "Sample In-Memory Binding"); + + /// + public override WotBindingCapability Capability { get; } = new WotBindingCapability( + BindingUri, + "Sample In-Memory Binding", + new WotBindingSource("urn:example:wot:mem", "1.0", WotBindingMaturity.UnofficialDraft, + note: "A sample custom binding for documentation and tests."), + new[] + { + WoTBindingCapabilityEnum.ReadProperty, + WoTBindingCapabilityEnum.WriteProperty, + WoTBindingCapabilityEnum.ObserveProperty + }, + new[] { "application/json", "text/plain" }, + isExecutable: true); + + /// + protected override IReadOnlyCollection Schemes => s_schemes; + + /// + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, "memv:"); + + /// + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var diagnostics = new List(); + if (!RequireHref(form, context, diagnostics, out string href) || + !TryParseUri(href, out Uri uri) || + !string.Equals(uri.Scheme, "mem", StringComparison.OrdinalIgnoreCase)) + { + diagnostics.Add(WotBindingDiagnostic.Error( + WotBindingDiagnosticCode.InvalidHref, + "The href is not a valid mem:// URI.", form.Pointer("href"))); + return WotBindingCompilation.Unsupported(diagnostics.ToArray()); + } + + string key = uri.AbsolutePath.Trim('/'); + ResolveCodec(form, context, out WotPayloadDescriptor payload); + WotEndpointDescriptor endpoint = MakeEndpoint(uri); + var addressing = new WotAddressingDescriptor(key); + + var entries = ImmutableArray.CreateBuilder(); + foreach ((string op, WoTBindingCapabilityEnum capability) in ResolveOperations(form, diagnostics)) + { + var operation = new WotOperationDescriptor(capability, op, capability.ToString()); + entries.Add(new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, capability, op, + endpoint, addressing, operation, payload, + ImmutableArray.Empty, Capability.IsExecutable)); + } + + return entries.Count == 0 + ? WotBindingCompilation.Unsupported(diagnostics.ToArray()) + : WotBindingCompilation.Supported(entries.ToImmutable(), diagnostics.ToImmutableArray()); + } + } + + /// The in-process key/value store the sample binding reads and writes. + public sealed class MemoryWotStore + { + /// Gets the value stored under a key. + public DataValue Get(string key) + => m_values.TryGetValue(key, out DataValue value) ? value : new DataValue(Variant.Null); + + /// Sets the value stored under a key. + public void Set(string key, DataValue value) => m_values[key] = value; + + private readonly ConcurrentDictionary m_values = + new ConcurrentDictionary(StringComparer.Ordinal); + } + + /// The executor for the sample in-memory binding. + public sealed class MemoryWotBindingExecutor : IWotBindingExecutor + { + /// Initializes a new sample executor over the supplied store. + public MemoryWotBindingExecutor(MemoryWotStore store) + { + m_store = store ?? throw new ArgumentNullException(nameof(store)); + } + + /// + public WotBindingIdentity Identity { get; } = + new WotBindingIdentity("example.mem", "1.0", MemoryWotBinder.BindingUri, "Sample In-Memory Executor"); + + /// + public bool CanExecute(WotCompiledForm form) + => form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal); + + /// + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "The channel is owned by the caller, who disposes it via DisposeAsync.")] + public ValueTask ActivateAsync( + WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + IWotBindingChannel channel = new MemoryWotBindingChannel(m_store, form); + return new ValueTask(channel); + } + + private readonly MemoryWotStore m_store; + } + + /// The live channel for the sample in-memory binding. + internal sealed class MemoryWotBindingChannel : IWotBindingChannel + { + public MemoryWotBindingChannel(MemoryWotStore store, WotCompiledForm form) + { + m_store = store; + m_form = form; + m_key = form.Addressing.Target; + } + + public WotCompiledForm Form => m_form; + + public ValueTask ReadAsync(CancellationToken cancellationToken = default) + => new ValueTask(new WotReadResult(StatusCodes.Good, m_store.Get(m_key))); + + public ValueTask WriteAsync(DataValue value, CancellationToken cancellationToken = default) + { + m_store.Set(m_key, value); + return new ValueTask(new WotWriteResult(StatusCodes.Good)); + } + + public ValueTask InvokeAsync( + IReadOnlyList inputs, CancellationToken cancellationToken = default) + => new ValueTask(new WotInvokeResult( + StatusCodes.BadNotSupported, null, "The sample binding has no actions.")); + + [System.Diagnostics.CodeAnalysis.SuppressMessage( + "Reliability", "CA2000:Dispose objects before losing scope", + Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")] + public ValueTask ObserveAsync( + Action onNotification, CancellationToken cancellationToken = default) + { + if (onNotification is null) + { + throw new ArgumentNullException(nameof(onNotification)); + } + var subscription = new PollingWotSubscription(m_form, token => + { + onNotification(new WotNotification(m_store.Get(m_key))); + return default; + }, TimeSpan.FromMilliseconds(200)); + return new ValueTask(subscription); + } + + public ValueTask SubscribeEventAsync( + Action onEvent, CancellationToken cancellationToken = default) + => ObserveAsync(onEvent, cancellationToken); + + public ValueTask DisposeAsync() => default; + + private readonly MemoryWotStore m_store; + private readonly WotCompiledForm m_form; + private readonly string m_key; + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotAffordanceForm.cs b/src/Opc.Ua.WotCon.Binding/WotAffordanceForm.cs new file mode 100644 index 0000000000..1b17a7b67b --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotAffordanceForm.cs @@ -0,0 +1,266 @@ +/* ======================================================================== + * 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.Immutable; +using System.Globalization; +using System.Text; +using System.Text.Json; + +namespace Opc.Ua.WotCon.Binding +{ + /// The kind of interaction affordance a form belongs to. + public enum WotAffordanceKind + { + /// A property affordance. + Property, + + /// An action affordance. + Action, + + /// An event affordance. + Event + } + + /// + /// An immutable description of a single WoT interaction-affordance form. It + /// carries the affordance metadata and a reflection-free snapshot of the form + /// and affordance JSON (cloned, so it is safe to retain on the immutable + /// registry snapshot) together with the RFC 6901 JSON Pointer that locates the + /// form in the originating document. Binders read protocol vocabulary terms + /// from ; the object performs no transport I/O. + /// + public sealed class WotAffordanceForm + { + /// Initializes a new immutable affordance form. + public WotAffordanceForm( + WotAffordanceKind kind, + string affordanceName, + ImmutableArray operations, + string? href, + string? contentType, + string? subprotocol, + ImmutableArray securitySchemes, + string jsonPointer, + JsonElement formElement, + JsonElement affordanceElement) + { + Kind = kind; + AffordanceName = affordanceName ?? string.Empty; + Operations = operations.IsDefault ? ImmutableArray.Empty : operations; + Href = href; + ContentType = contentType; + Subprotocol = subprotocol; + SecuritySchemes = securitySchemes.IsDefault ? ImmutableArray.Empty : securitySchemes; + JsonPointer = jsonPointer ?? string.Empty; + FormElement = formElement; + AffordanceElement = affordanceElement; + } + + /// Gets the affordance kind (property / action / event). + public WotAffordanceKind Kind { get; } + + /// Gets the affordance (property / action / event) name. + public string AffordanceName { get; } + + /// + /// Gets the resolved interaction operations (op) for the form. + /// Defaults per the WoT specification are applied when the form omits + /// op: read/write for properties, invoke for actions and + /// subscribe/unsubscribe for events. + /// + public ImmutableArray Operations { get; } + + /// Gets the form target href, if any. + public string? Href { get; } + + /// Gets the form contentType, if any. + public string? ContentType { get; } + + /// Gets the form subprotocol, if any. + public string? Subprotocol { get; } + + /// Gets the security scheme names required by the form (no secrets). + public ImmutableArray SecuritySchemes { get; } + + /// Gets the RFC 6901 JSON Pointer that locates the form. + public string JsonPointer { get; } + + /// Gets the reflection-free JSON snapshot of the form object. + public JsonElement FormElement { get; } + + /// Gets the reflection-free JSON snapshot of the owning affordance object. + public JsonElement AffordanceElement { get; } + + /// Gets whether the form declares the supplied case-insensitive op. + public bool HasOperation(string op) + { + foreach (string value in Operations) + { + if (string.Equals(value, op, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + return false; + } + + /// Builds a child JSON Pointer under the form (for example href). + public string Pointer(string childToken) + { + if (string.IsNullOrEmpty(childToken)) + { + return JsonPointer; + } + return JsonPointer + "/" + EscapePointerToken(childToken); + } + + /// + /// Reads a string-valued term from the form object, honouring both the + /// plain and a colon-prefixed vocabulary form (for example href or + /// modv:function). + /// + public bool TryGetString(string term, out string value) + { + if (FormElement.ValueKind == JsonValueKind.Object && + FormElement.TryGetProperty(term, out JsonElement element) && + element.ValueKind == JsonValueKind.String) + { + value = element.GetString() ?? string.Empty; + return true; + } + value = string.Empty; + return false; + } + + /// Reads a boolean-valued term from the form object. + public bool TryGetBoolean(string term, out bool value) + { + if (FormElement.ValueKind == JsonValueKind.Object && + FormElement.TryGetProperty(term, out JsonElement element) && + (element.ValueKind == JsonValueKind.True || element.ValueKind == JsonValueKind.False)) + { + value = element.GetBoolean(); + return true; + } + value = false; + return false; + } + + /// Reads an integer-valued term from the form object. + public bool TryGetInt32(string term, out int value) + { + if (FormElement.ValueKind == JsonValueKind.Object && + FormElement.TryGetProperty(term, out JsonElement element)) + { + if (element.ValueKind == JsonValueKind.Number && element.TryGetInt32(out value)) + { + return true; + } + if (element.ValueKind == JsonValueKind.String && + int.TryParse(element.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out value)) + { + return true; + } + } + value = 0; + return false; + } + + /// + /// Reads a string-array-valued term from the form object (for example + /// uav:eventFields). Non-string / empty array entries are + /// skipped. + /// + public bool TryGetStringArray(string term, out ImmutableArray values) + { + if (FormElement.ValueKind == JsonValueKind.Object && + FormElement.TryGetProperty(term, out JsonElement element) && + element.ValueKind == JsonValueKind.Array) + { + ImmutableArray.Builder builder = + ImmutableArray.CreateBuilder(element.GetArrayLength()); + foreach (JsonElement item in element.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + string? value = item.GetString(); + if (!string.IsNullOrEmpty(value)) + { + builder.Add(value!); + } + } + } + values = builder.ToImmutable(); + return values.Length > 0; + } + values = ImmutableArray.Empty; + return false; + } + + /// Escapes a single RFC 6901 JSON Pointer reference token. + public static string EscapePointerToken(string token) + { + if (string.IsNullOrEmpty(token)) + { + return token; + } + bool needsEscape = false; + foreach (char c in token) + { + if (c == '~' || c == '/') + { + needsEscape = true; + break; + } + } + if (!needsEscape) + { + return token; + } + var builder = new StringBuilder(token.Length + 4); + foreach (char c in token) + { + switch (c) + { + case '~': + builder.Append("~0"); + break; + case '/': + builder.Append("~1"); + break; + default: + builder.Append(c); + break; + } + } + return builder.ToString(); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingBounds.cs b/src/Opc.Ua.WotCon.Binding/WotBindingBounds.cs new file mode 100644 index 0000000000..6a4bf685e3 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingBounds.cs @@ -0,0 +1,71 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// Safety bounds applied while validating and executing binding forms. All + /// bounds are conservative defaults that planners and executors enforce to + /// avoid unbounded addressing, payloads or fan-out. + /// + public sealed class WotBindingBounds + { + /// Gets the shared default bounds. + public static WotBindingBounds Default { get; } = new WotBindingBounds(); + + /// Gets or sets the maximum accepted href / URI length. + public int MaxUriLength { get; set; } = 2048; + + /// Gets or sets the maximum accepted MQTT topic length. + public int MaxTopicLength { get; set; } = 65535; + + /// Gets or sets the maximum accepted request / response payload size (bytes). + public int MaxPayloadBytes { get; set; } = 1024 * 1024; + + /// Gets or sets the maximum Modbus register quantity for a read. + public int MaxRegisterQuantity { get; set; } = 125; + + /// Gets or sets the maximum Modbus coil / discrete-input quantity for a read. + public int MaxCoilQuantity { get; set; } = 2000; + + /// Gets or sets the default operation timeout applied by executors. + public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(30); + + /// Validates a numeric bound and throws when it is non-positive. + public static void EnsurePositive(int value, string name) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException(name, value, "The value must be positive."); + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingCapability.cs b/src/Opc.Ua.WotCon.Binding/WotBindingCapability.cs new file mode 100644 index 0000000000..2b845e46e2 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingCapability.cs @@ -0,0 +1,120 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// The immutable, browseable capability snapshot advertised by a protocol + /// binder. It captures the version-pinned document the binder implements, the + /// interaction operations it supports, the content types it can encode and + /// whether it is executable (a planner-only binder validates and compiles but + /// performs no transport I/O). The snapshot is projected onto the 1.1 registry + /// SupportedBindings nodes and into refresh results. + /// + public sealed class WotBindingCapability + { + /// Initializes a new immutable capability snapshot. + /// The protocol-binding vocabulary URI. + /// A human-readable binding title. + /// The version-pinned document the binder implements. + /// The interaction operations the binding supports. + /// The content types the binding produces / consumes. + /// Whether a runtime executor is available. + public WotBindingCapability( + string bindingUri, + string title, + WotBindingSource source, + IEnumerable operations, + IEnumerable contentTypes, + bool isExecutable) + { + BindingUri = bindingUri ?? throw new ArgumentNullException(nameof(bindingUri)); + Title = title ?? string.Empty; + Source = source ?? throw new ArgumentNullException(nameof(source)); + Operations = operations is null + ? ImmutableArray.Empty + : operations.Distinct().ToImmutableArray(); + ContentTypes = contentTypes is null + ? ImmutableArray.Empty + : contentTypes.Where(c => !string.IsNullOrEmpty(c)).Distinct(StringComparer.Ordinal) + .ToImmutableArray(); + IsExecutable = isExecutable; + } + + /// Gets the protocol-binding vocabulary URI. + public string BindingUri { get; } + + /// Gets the human-readable binding title. + public string Title { get; } + + /// Gets the version-pinned document the binder implements. + public WotBindingSource Source { get; } + + /// Gets the interaction operations the binding supports. + public ImmutableArray Operations { get; } + + /// Gets the content types the binding produces / consumes. + public ImmutableArray ContentTypes { get; } + + /// + /// Gets whether a runtime executor is available. A planner-only binder + /// (for example BACnet, PROFINET, LoRaWAN or CoAP in this build) validates + /// and compiles binding plans but reports false so the runtime + /// treats materialized affordances as non-executable. + /// + public bool IsExecutable { get; } + + /// Gets whether the binding declares the supplied operation. + public bool Supports(WoTBindingCapabilityEnum operation) + => Operations.Contains(operation); + + /// + /// Projects this snapshot onto the generated + /// for the registry nodes and + /// refresh results. No credentials or secrets are ever included. + /// + public WoTBindingCapabilityDataType ToDataType() + { + return new WoTBindingCapabilityDataType + { + BindingUri = BindingUri, + Title = Title, + ProfileVersion = Source.Version, + DraftMaturity = Source.MaturityText, + Capabilities = Operations.ToArray(), + ContentTypes = ContentTypes.ToArray() + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingDiagnostic.cs b/src/Opc.Ua.WotCon.Binding/WotBindingDiagnostic.cs new file mode 100644 index 0000000000..4057ce0aef --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingDiagnostic.cs @@ -0,0 +1,175 @@ +/* ======================================================================== + * 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.Globalization; +using Opc.Ua.Wot; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// Stable diagnostic codes emitted while validating and compiling a WoT + /// interaction form into a binding plan. Codes are grouped by concern so + /// operators can filter without matching on message text. + /// + public enum WotBindingDiagnosticCode + { + /// No specific code. + None = 0, + + /// The form has no href. + MissingHref = 7000, + + /// The href is not a valid URI for this binding. + InvalidHref = 7001, + + /// The href scheme is not handled by this binding. + UnsupportedScheme = 7002, + + /// The requested op is not compatible with the affordance kind. + IncompatibleOperation = 7003, + + /// The requested op is not supported by this binding. + UnsupportedOperation = 7004, + + /// The contentType is missing where the binding requires it. + MissingContentType = 7005, + + /// The contentType is not supported by this binding. + UnsupportedContentType = 7006, + + /// A required binding-specific field is missing. + MissingRequiredField = 7007, + + /// A binding-specific field has an invalid value. + InvalidFieldValue = 7008, + + /// A vocabulary term is not defined by the pinned document. + UnknownVocabularyTerm = 7009, + + /// Two fields conflict and cannot both be honoured. + ConflictingFields = 7010, + + /// A referenced security scheme is not declared by the document. + UnknownSecurityScheme = 7011, + + /// + /// The binding validates and compiles but has no runtime executor, so the + /// affordance is materialized as non-executable. + /// + NonExecutableBinding = 7012, + + /// A value exceeded a configured safety bound. + BoundsExceeded = 7013, + + /// An informational note about how the form was interpreted. + Informational = 7014 + } + + /// + /// A single structured diagnostic produced while validating or compiling a + /// binding form. Every diagnostic carries a severity, a stable code and an + /// RFC 6901 JSON Pointer into the originating Thing Description / Thing Model + /// so callers can locate the offending term precisely. + /// + public sealed class WotBindingDiagnostic + { + /// Initializes a new immutable binding diagnostic. + /// The severity of the diagnostic. + /// The stable diagnostic code. + /// A human-readable message. + /// The RFC 6901 JSON Pointer into the document. + /// The offending vocabulary term, if any. + public WotBindingDiagnostic( + WotDiagnosticSeverity severity, + WotBindingDiagnosticCode code, + string message, + string? jsonPointer = null, + string? term = null) + { + Severity = severity; + Code = code; + Message = message ?? throw new ArgumentNullException(nameof(message)); + JsonPointer = jsonPointer; + Term = term; + } + + /// Gets the severity of the diagnostic. + public WotDiagnosticSeverity Severity { get; } + + /// Gets the stable diagnostic code. + public WotBindingDiagnosticCode Code { get; } + + /// Gets the human-readable message. + public string Message { get; } + + /// Gets the RFC 6901 JSON Pointer into the document, if any. + public string? JsonPointer { get; } + + /// Gets the offending vocabulary term, if any. + public string? Term { get; } + + /// Gets whether this diagnostic is an error. + public bool IsError => Severity == WotDiagnosticSeverity.Error; + + /// Creates an error diagnostic. + public static WotBindingDiagnostic Error( + WotBindingDiagnosticCode code, string message, string? jsonPointer = null, string? term = null) + => new WotBindingDiagnostic(WotDiagnosticSeverity.Error, code, message, jsonPointer, term); + + /// Creates a warning diagnostic. + public static WotBindingDiagnostic Warning( + WotBindingDiagnosticCode code, string message, string? jsonPointer = null, string? term = null) + => new WotBindingDiagnostic(WotDiagnosticSeverity.Warning, code, message, jsonPointer, term); + + /// Creates an informational diagnostic. + public static WotBindingDiagnostic Info( + WotBindingDiagnosticCode code, string message, string? jsonPointer = null, string? term = null) + => new WotBindingDiagnostic(WotDiagnosticSeverity.Info, code, message, jsonPointer, term); + + /// Projects this diagnostic onto the shared model. + public WotDiagnostic ToWotDiagnostic() + { + WotLocation? location = JsonPointer is null ? null : WotLocation.FromPointer(JsonPointer); + return new WotDiagnostic(Severity, WotDiagnosticCode.ValidationError, Message, location); + } + + /// + public override string ToString() + { + return string.Format( + CultureInfo.InvariantCulture, + "{0} WOTB{1:D4}: {2}{3}", + Severity, + (int)Code, + Message, + JsonPointer is null ? string.Empty : " [" + JsonPointer + "]"); + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingIdentification.cs b/src/Opc.Ua.WotCon.Binding/WotBindingIdentification.cs new file mode 100644 index 0000000000..de2e29f408 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingIdentification.cs @@ -0,0 +1,179 @@ +/* ======================================================================== + * 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.Immutable; + +namespace Opc.Ua.WotCon.Binding +{ + /// How a binder matched an interaction form. + public enum WotBindingMatchKind + { + /// The binder does not handle the form. + None = 0, + + /// The form's href URI scheme is handled by the binder. + Scheme = 1, + + /// The form's subprotocol is handled by the binder. + Subprotocol = 2, + + /// A binding-specific vocabulary term identifies the binding. + Vocabulary = 3, + + /// A pinned identification rule (scheme + term + shape) matched. + PinnedRule = 4, + + /// The resource explicitly pinned this binder by id / version. + ExplicitBindingId = 5 + } + + /// + /// The result of asking a binder whether it handles an interaction form. + /// Selection is deterministic: the registry chooses the highest + /// and breaks ties by ordinal binder id@version. + /// + public readonly struct WotBindingMatch : IEquatable + { + private WotBindingMatch(bool isMatch, WotBindingMatchKind kind, int priority) + { + IsMatch = isMatch; + Kind = kind; + Priority = priority; + } + + /// A "does not handle" result. + public static WotBindingMatch NoMatch { get; } = new WotBindingMatch(false, WotBindingMatchKind.None, 0); + + /// Gets whether the binder handles the form. + public bool IsMatch { get; } + + /// Gets the kind of the match. + public WotBindingMatchKind Kind { get; } + + /// Gets the selection priority (higher wins). + public int Priority { get; } + + /// Creates a positive match with an explicit priority. + public static WotBindingMatch Match(WotBindingMatchKind kind, int priority) + => new WotBindingMatch(true, kind, priority); + + /// + /// Creates a positive match whose priority defaults to the match kind, so + /// an explicit binding-id pin beats a pinned rule, which beats a + /// vocabulary match, which beats a subprotocol match, which beats a bare + /// scheme match. + /// + public static WotBindingMatch Match(WotBindingMatchKind kind) + => new WotBindingMatch(true, kind, (int)kind * 100); + + /// + public bool Equals(WotBindingMatch other) + => IsMatch == other.IsMatch && Kind == other.Kind && Priority == other.Priority; + + /// + public override bool Equals(object? obj) => obj is WotBindingMatch other && Equals(other); + + /// + public override int GetHashCode() + => ((IsMatch ? 1 : 0) * 31 + (int)Kind) * 31 + Priority; + + /// Equality operator. + public static bool operator ==(WotBindingMatch left, WotBindingMatch right) => left.Equals(right); + + /// Inequality operator. + public static bool operator !=(WotBindingMatch left, WotBindingMatch right) => !left.Equals(right); + } + + /// + /// Context supplied to a binder while it decides whether it handles a form: + /// the binding ids explicitly pinned on the resource / registry and the + /// document kind. Explicit pins let operators force a specific binder even + /// when several could match by scheme. + /// + public sealed class WotBindingSelectionContext + { + /// An empty context (no explicit pins). + public static WotBindingSelectionContext Empty { get; } = + new WotBindingSelectionContext(ImmutableArray.Empty, ImmutableArray.Empty); + + /// Initializes a new selection context. + /// Explicit binder id / key pins on the resource. + /// Explicit binding vocabulary URI pins on the resource. + public WotBindingSelectionContext( + ImmutableArray selectedBindingIds, + ImmutableArray selectedBindingUris) + { + SelectedBindingIds = selectedBindingIds.IsDefault ? ImmutableArray.Empty : selectedBindingIds; + SelectedBindingUris = selectedBindingUris.IsDefault ? ImmutableArray.Empty : selectedBindingUris; + } + + /// Gets the binder id / key pins explicitly selected on the resource. + public ImmutableArray SelectedBindingIds { get; } + + /// Gets the binding vocabulary URI pins explicitly selected on the resource. + public ImmutableArray SelectedBindingUris { get; } + + /// Gets whether the identity is explicitly pinned by id, key or URI. + public bool IsPinned(WotBindingIdentity identity) + { + foreach (string pin in SelectedBindingIds) + { + if (string.Equals(pin, identity.Id, StringComparison.Ordinal) || + string.Equals(pin, identity.Key, StringComparison.Ordinal)) + { + return true; + } + } + foreach (string uri in SelectedBindingUris) + { + if (string.Equals(uri, identity.BindingUri, StringComparison.Ordinal)) + { + return true; + } + } + return false; + } + } + + /// + /// Identifies whether a binder handles a given interaction form. Implementations + /// pin exact rules (scheme, subprotocol, vocabulary term or explicit binding id) + /// so selection is deterministic and does not rely on the URI scheme alone. + /// + public interface IWotBindingIdentification + { + /// + /// Returns a match describing whether and how strongly this binder handles + /// the supplied form. Returns when the + /// binder does not handle the form. + /// + WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingIdentity.cs b/src/Opc.Ua.WotCon.Binding/WotBindingIdentity.cs new file mode 100644 index 0000000000..935c3eca6a --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingIdentity.cs @@ -0,0 +1,95 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// The stable identity of a protocol binder. A binder is uniquely identified + /// by its and , so multiple versions of + /// the same binding may be registered and coexist. Deterministic selection + /// uses the binding identity together with pinned identification rules rather + /// than the URI scheme alone. + /// + public sealed class WotBindingIdentity : IEquatable + { + /// Initializes a new immutable binder identity. + /// The stable, human-meaningful binder id (for example "opc.http"). + /// The binder version (for example "1.1.0"). + /// The protocol-binding vocabulary URI. + /// An optional human-readable display name. + public WotBindingIdentity(string id, string version, string bindingUri, string? displayName = null) + { + Id = id ?? throw new ArgumentNullException(nameof(id)); + Version = version ?? throw new ArgumentNullException(nameof(version)); + BindingUri = bindingUri ?? throw new ArgumentNullException(nameof(bindingUri)); + DisplayName = string.IsNullOrEmpty(displayName) ? id : displayName!; + } + + /// Gets the stable, human-meaningful binder id. + public string Id { get; } + + /// Gets the binder version. + public string Version { get; } + + /// Gets the protocol-binding vocabulary URI. + public string BindingUri { get; } + + /// Gets the human-readable display name. + public string DisplayName { get; } + + /// Gets the composite selection key (id@version). + public string Key => Id + "@" + Version; + + /// + public bool Equals(WotBindingIdentity? other) + { + return other is not null && + string.Equals(Id, other.Id, StringComparison.Ordinal) && + string.Equals(Version, other.Version, StringComparison.Ordinal); + } + + /// + public override bool Equals(object? obj) => Equals(obj as WotBindingIdentity); + + /// + public override int GetHashCode() + { + unchecked + { + return (StringComparer.Ordinal.GetHashCode(Id) * 397) ^ + StringComparer.Ordinal.GetHashCode(Version); + } + } + + /// + public override string ToString() => Key; + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingMaturity.cs b/src/Opc.Ua.WotCon.Binding/WotBindingMaturity.cs new file mode 100644 index 0000000000..66001af076 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingMaturity.cs @@ -0,0 +1,150 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// The standards maturity of the protocol-binding document a binder was + /// pinned against. A binder must advertise the maturity of the exact + /// document it implements so callers can distinguish a normative mapping + /// from an editor's draft. The W3C Binding Templates registry is a pilot and + /// is intentionally never reported as . + /// + public enum WotBindingMaturity + { + /// The maturity is not known. + Unknown = 0, + + /// An unofficial draft (for example a GitHub working file). + UnofficialDraft, + + /// A W3C Editor's Draft. + EditorsDraft, + + /// A W3C First Public / Working Draft. + WorkingDraft, + + /// A W3C Candidate Recommendation. + CandidateRecommendation, + + /// A W3C Proposed Recommendation. + ProposedRecommendation, + + /// A W3C Recommendation (a normative, published standard). + Recommendation, + + /// A W3C Working Group Note. + Note, + + /// A published OPC Foundation specification (for example OPC 10101). + OpcSpecification, + + /// + /// An entry that is Current in the W3C Binding Templates registry. This + /// value is reserved: the registry is a pilot and currently empty, so no + /// shipped binder claims it. + /// + RegistryCurrent + } + + /// + /// An immutable, version-pinned reference to the exact specification document + /// a protocol binder implements. Every planner pins its source so operators + /// can audit precisely which mapping is enforced. + /// + public sealed class WotBindingSource + { + /// Initializes a new immutable binding source. + /// The canonical document URL. + /// The pinned version, date or tag of the document. + /// The standards maturity of the document. + /// The pinned VCS commit or revision, if any. + /// The ISO-8601 date the document was pinned, if any. + /// An optional caveat (for example "registry pilot is empty"). + public WotBindingSource( + string specificationUri, + string version, + WotBindingMaturity maturity, + string? commit = null, + string? retrieved = null, + string? note = null) + { + SpecificationUri = specificationUri ?? throw new ArgumentNullException(nameof(specificationUri)); + Version = version ?? string.Empty; + Maturity = maturity; + Commit = commit; + Retrieved = retrieved; + Note = note; + } + + /// Gets the canonical document URL that was pinned. + public string SpecificationUri { get; } + + /// Gets the pinned version, date or tag of the document. + public string Version { get; } + + /// Gets the standards maturity of the pinned document. + public WotBindingMaturity Maturity { get; } + + /// Gets the pinned VCS commit or revision, if any. + public string? Commit { get; } + + /// Gets the date the document was pinned, if any. + public string? Retrieved { get; } + + /// Gets an optional caveat about the source, if any. + public string? Note { get; } + + /// + /// Gets the stable text token for reported in the + /// browseable DraftMaturity capability field. + /// + public string MaturityText => MaturityToText(Maturity); + + /// Maps a to its stable text token. + public static string MaturityToText(WotBindingMaturity maturity) + { + return maturity switch + { + WotBindingMaturity.UnofficialDraft => "UnofficialDraft", + WotBindingMaturity.EditorsDraft => "ED", + WotBindingMaturity.WorkingDraft => "WD", + WotBindingMaturity.CandidateRecommendation => "CR", + WotBindingMaturity.ProposedRecommendation => "PR", + WotBindingMaturity.Recommendation => "REC", + WotBindingMaturity.Note => "NOTE", + WotBindingMaturity.OpcSpecification => "OPC", + WotBindingMaturity.RegistryCurrent => "RegistryCurrent", + _ => "Unknown" + }; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingPlan.cs b/src/Opc.Ua.WotCon.Binding/WotBindingPlan.cs new file mode 100644 index 0000000000..5e7f0cb91d --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingPlan.cs @@ -0,0 +1,205 @@ +/* ======================================================================== + * 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.Immutable; +using System.Linq; +using System.Text.Json; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// A request to validate and compile a single resource's interaction forms + /// into a binding plan. The request is side-effect free and carries the + /// extracted forms, the secret-free security definitions, the base URI and the + /// explicit binder selection pinned on the resource. + /// + public sealed class WotBindingPlanRequest + { + /// Initializes a new plan request. + public WotBindingPlanRequest( + string resourceXid, + WoTDocumentKindEnum kind, + ImmutableArray forms, + ImmutableDictionary? securityDefinitions = null, + string? baseUri = null, + WotBindingSelectionContext? selection = null) + { + ResourceXid = resourceXid ?? string.Empty; + Kind = kind; + Forms = forms.IsDefault ? ImmutableArray.Empty : forms; + SecurityDefinitions = securityDefinitions ?? ImmutableDictionary.Empty; + BaseUri = baseUri; + Selection = selection ?? WotBindingSelectionContext.Empty; + } + + /// Gets the resource xid. + public string ResourceXid { get; } + + /// Gets the document kind. + public WoTDocumentKindEnum Kind { get; } + + /// Gets the affordance forms parsed from the document. + public ImmutableArray Forms { get; } + + /// Gets the secret-free security definitions declared by the document. + public ImmutableDictionary SecurityDefinitions { get; } + + /// Gets the Thing base URI used for relative href resolution, if any. + public string? BaseUri { get; } + + /// Gets the explicit binder selection pinned on the resource. + public WotBindingSelectionContext Selection { get; } + + /// Builds a plan context from this request. + public WotBindingPlanContext CreateContext(IWotCodecRegistry codecs, WotBindingBounds bounds) + => new WotBindingPlanContext(SecurityDefinitions, codecs, Kind, BaseUri, bounds); + + /// + /// Builds a plan request from a WoT document: it extracts the forms, the + /// base URI and the secret-free security definitions. + /// + public static WotBindingPlanRequest FromDocument( + string resourceXid, + WoTDocumentKindEnum kind, + ReadOnlyMemory document, + int maxJsonDepth = 64, + WotBindingSelectionContext? selection = null) + { + ImmutableArray forms = WotFormExtractor.Extract(document, maxJsonDepth); + ImmutableDictionary definitions = ReadSecurityDefinitions(document, maxJsonDepth); + string? baseUri = ReadBase(document, maxJsonDepth); + return new WotBindingPlanRequest(resourceXid, kind, forms, definitions, baseUri, selection); + } + + private static ImmutableDictionary ReadSecurityDefinitions( + ReadOnlyMemory document, int maxJsonDepth) + { + var builder = ImmutableDictionary.CreateBuilder(StringComparer.Ordinal); + try + { + var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth <= 0 ? 64 : maxJsonDepth }; + using JsonDocument json = JsonDocument.Parse(document, options); + if (json.RootElement.ValueKind == JsonValueKind.Object && + json.RootElement.TryGetProperty("securityDefinitions", out JsonElement definitions) && + definitions.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty definition in definitions.EnumerateObject()) + { + builder[definition.Name] = WotSecurityDefinition.Parse(definition.Name, definition.Value); + } + } + } + catch (JsonException) + { + } + return builder.ToImmutable(); + } + + private static string? ReadBase(ReadOnlyMemory document, int maxJsonDepth) + { + try + { + var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth <= 0 ? 64 : maxJsonDepth }; + using JsonDocument json = JsonDocument.Parse(document, options); + if (json.RootElement.ValueKind == JsonValueKind.Object && + json.RootElement.TryGetProperty("base", out JsonElement baseElement) && + baseElement.ValueKind == JsonValueKind.String) + { + return baseElement.GetString(); + } + } + catch (JsonException) + { + } + return null; + } + } + + /// + /// The immutable result of preparing bindings for one resource. It holds the + /// participating capability snapshots, the compiled (executable and + /// non-executable) forms, the forms no binder validated and the structured + /// diagnostics. A strict closure fails when is + /// false; otherwise unsupported forms materialize as degraded nodes. + /// + public sealed class WotBindingPlan + { + /// Initializes a new immutable binding plan. + public WotBindingPlan( + string resourceXid, + ImmutableArray capabilities, + ImmutableArray compiledForms, + ImmutableArray unsupportedForms, + ImmutableArray diagnostics) + { + ResourceXid = resourceXid ?? string.Empty; + Capabilities = capabilities.IsDefault + ? ImmutableArray.Empty : capabilities; + CompiledForms = compiledForms.IsDefault + ? ImmutableArray.Empty : compiledForms; + UnsupportedForms = unsupportedForms.IsDefault + ? ImmutableArray.Empty : unsupportedForms; + Diagnostics = diagnostics.IsDefault + ? ImmutableArray.Empty : diagnostics; + } + + /// An empty plan (no forms, no capabilities). + public static WotBindingPlan Empty { get; } = new WotBindingPlan( + string.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty, + ImmutableArray.Empty); + + /// Gets the resource xid the plan was prepared for. + public string ResourceXid { get; } + + /// Gets the participating binding capability snapshots. + public ImmutableArray Capabilities { get; } + + /// Gets the compiled (executable and non-executable) forms. + public ImmutableArray CompiledForms { get; } + + /// Gets the forms no binder validated. + public ImmutableArray UnsupportedForms { get; } + + /// Gets the structured diagnostics produced during Prepare. + public ImmutableArray Diagnostics { get; } + + /// Gets whether every form was validated by a binder. + public bool FullySupported => UnsupportedForms.IsEmpty; + + /// Gets whether the plan compiled at least one executable form. + public bool HasExecutableForms => CompiledForms.Any(f => f.IsExecutable); + + /// Gets whether the plan compiled at least one non-executable form. + public bool HasNonExecutableForms => CompiledForms.Any(f => !f.IsExecutable); + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotBindingPlanModel.cs b/src/Opc.Ua.WotCon.Binding/WotBindingPlanModel.cs new file mode 100644 index 0000000000..6e769613fa --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotBindingPlanModel.cs @@ -0,0 +1,203 @@ +/* ======================================================================== + * 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.Immutable; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// Maps WoT op tokens to and from the generated + /// operations. + /// + public static class WotOperations + { + /// Maps an op token to a capability operation. + public static bool TryMap(string op, out WoTBindingCapabilityEnum operation) + { + switch (op) + { + case "readproperty": + operation = WoTBindingCapabilityEnum.ReadProperty; + return true; + case "writeproperty": + operation = WoTBindingCapabilityEnum.WriteProperty; + return true; + case "observeproperty": + operation = WoTBindingCapabilityEnum.ObserveProperty; + return true; + case "unobserveproperty": + operation = WoTBindingCapabilityEnum.ObserveProperty; + return true; + case "invokeaction": + operation = WoTBindingCapabilityEnum.InvokeAction; + return true; + case "subscribeevent": + operation = WoTBindingCapabilityEnum.SubscribeEvent; + return true; + case "unsubscribeevent": + operation = WoTBindingCapabilityEnum.UnsubscribeEvent; + return true; + default: + operation = default; + return false; + } + } + + /// Gets whether the op token is compatible with the affordance kind. + public static bool IsCompatible(WotAffordanceKind kind, string op) + { + switch (kind) + { + case WotAffordanceKind.Property: + return op is "readproperty" or "writeproperty" or "observeproperty" or "unobserveproperty"; + case WotAffordanceKind.Action: + return op is "invokeaction" or "queryaction" or "cancelaction"; + case WotAffordanceKind.Event: + return op is "subscribeevent" or "unsubscribeevent"; + default: + return false; + } + } + } + + /// + /// Immutable, transport-neutral endpoint metadata compiled from a form. The + /// well-known members expose the parsed endpoint; the + /// bag carries binding-specific additions. + /// + public sealed class WotEndpointDescriptor + { + /// Initializes a new immutable endpoint descriptor. + public WotEndpointDescriptor( + string scheme, + string? host, + int port, + string baseUri, + ImmutableDictionary? metadata = null) + { + Scheme = scheme ?? string.Empty; + Host = host; + Port = port; + BaseUri = baseUri ?? string.Empty; + Metadata = metadata ?? ImmutableDictionary.Empty; + } + + /// Gets the endpoint URI scheme (for example http, mqtt). + public string Scheme { get; } + + /// Gets the endpoint host, if applicable. + public string? Host { get; } + + /// Gets the endpoint port, or -1 when not applicable. + public int Port { get; } + + /// Gets the canonical endpoint / base URI. + public string BaseUri { get; } + + /// Gets binding-specific endpoint metadata. + public ImmutableDictionary Metadata { get; } + } + + /// Immutable, transport-neutral addressing metadata compiled from a form. + public sealed class WotAddressingDescriptor + { + /// Initializes a new immutable addressing descriptor. + public WotAddressingDescriptor(string target, ImmutableDictionary? metadata = null) + { + Target = target ?? string.Empty; + Metadata = metadata ?? ImmutableDictionary.Empty; + } + + /// + /// Gets the addressing target: an HTTP path/URL, an MQTT topic, a Modbus + /// register reference or an OPC UA NodeId, depending on the binding. + /// + public string Target { get; } + + /// Gets binding-specific addressing metadata. + public ImmutableDictionary Metadata { get; } + } + + /// Immutable operation metadata compiled from a form. + public sealed class WotOperationDescriptor + { + /// Initializes a new immutable operation descriptor. + public WotOperationDescriptor( + WoTBindingCapabilityEnum operation, + string opToken, + string method, + ImmutableDictionary? metadata = null) + { + Operation = operation; + OpToken = opToken ?? string.Empty; + Method = method ?? string.Empty; + Metadata = metadata ?? ImmutableDictionary.Empty; + } + + /// Gets the resolved capability operation. + public WoTBindingCapabilityEnum Operation { get; } + + /// Gets the originating WoT op token. + public string OpToken { get; } + + /// + /// Gets the concrete protocol method: an HTTP verb, a Modbus function + /// code mnemonic, an MQTT publish / subscribe verb or an OPC UA service. + /// + public string Method { get; } + + /// Gets binding-specific operation metadata. + public ImmutableDictionary Metadata { get; } + } + + /// Immutable payload metadata compiled from a form. + public sealed class WotPayloadDescriptor + { + /// Initializes a new immutable payload descriptor. + public WotPayloadDescriptor( + string contentType, + string codecId, + ImmutableDictionary? metadata = null) + { + ContentType = contentType ?? string.Empty; + CodecId = codecId ?? string.Empty; + Metadata = metadata ?? ImmutableDictionary.Empty; + } + + /// Gets the resolved content type. + public string ContentType { get; } + + /// Gets the id of the selected payload codec. + public string CodecId { get; } + + /// Gets binding-specific payload metadata (for example numeric type / byte order). + public ImmutableDictionary Metadata { get; } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotFormExtractor.cs b/src/Opc.Ua.WotCon.Binding/WotFormExtractor.cs new file mode 100644 index 0000000000..3484e3441b --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotFormExtractor.cs @@ -0,0 +1,259 @@ +/* ======================================================================== + * 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.Immutable; +using System.Text.Json; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// Extracts the interaction-affordance forms (property / action / event) from + /// a WoT Thing Description or Thing Model so binders can classify and compile + /// them. Extraction is read-only, reflection-free and never performs transport + /// I/O. Default op values are resolved per the WoT specification when a + /// form omits them, and per-form security requirements fall back to the + /// Thing-level default. + /// + public static class WotFormExtractor + { + /// Extracts the affordance forms from a WoT document. + public static ImmutableArray Extract( + ReadOnlyMemory document, int maxJsonDepth = 64) + { + var forms = ImmutableArray.CreateBuilder(); + try + { + var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth <= 0 ? 64 : maxJsonDepth }; + using JsonDocument json = JsonDocument.Parse(document, options); + JsonElement root = json.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return forms.ToImmutable(); + } + + ImmutableArray thingSecurity = ReadSecurity(root); + Collect(root, "properties", WotAffordanceKind.Property, thingSecurity, forms); + Collect(root, "actions", WotAffordanceKind.Action, thingSecurity, forms); + Collect(root, "events", WotAffordanceKind.Event, thingSecurity, forms); + } + catch (JsonException) + { + // Malformed documents are handled upstream by the converter; the + // binder layer simply produces no forms. + } + return forms.ToImmutable(); + } + + private static void Collect( + JsonElement root, + string collection, + WotAffordanceKind kind, + ImmutableArray thingSecurity, + ImmutableArray.Builder forms) + { + if (!root.TryGetProperty(collection, out JsonElement affordances) || + affordances.ValueKind != JsonValueKind.Object) + { + return; + } + + foreach (JsonProperty affordance in affordances.EnumerateObject()) + { + if (affordance.Value.ValueKind != JsonValueKind.Object) + { + continue; + } + JsonElement affordanceElement = affordance.Value.Clone(); + string affordanceName = affordance.Name; + string affordancePointer = "/" + collection + "/" + + WotAffordanceForm.EscapePointerToken(affordanceName); + + if (!affordanceElement.TryGetProperty("forms", out JsonElement formsElement) || + formsElement.ValueKind != JsonValueKind.Array) + { + // An affordance without forms still requires a binder; emit a + // formless descriptor so a strict closure treats it as + // unsupported. + forms.Add(new WotAffordanceForm( + kind, affordanceName, DefaultOperations(kind, affordanceElement), + null, null, null, thingSecurity, affordancePointer + "/forms", + default, affordanceElement)); + continue; + } + + int index = 0; + foreach (JsonElement form in formsElement.EnumerateArray()) + { + string formPointer = affordancePointer + "/forms/" + + index.ToString(System.Globalization.CultureInfo.InvariantCulture); + index++; + if (form.ValueKind != JsonValueKind.Object) + { + continue; + } + JsonElement formElement = form.Clone(); + ImmutableArray ops = ReadOperations(formElement, kind, affordanceElement); + ImmutableArray security = ReadSecurity(formElement); + if (security.IsEmpty) + { + security = thingSecurity; + } + forms.Add(new WotAffordanceForm( + kind, + affordanceName, + ops, + GetString(formElement, "href"), + GetString(formElement, "contentType"), + GetString(formElement, "subprotocol"), + security, + formPointer, + formElement, + affordanceElement)); + } + } + } + + private static ImmutableArray ReadOperations( + JsonElement form, WotAffordanceKind kind, JsonElement affordance) + { + if (form.TryGetProperty("op", out JsonElement op)) + { + if (op.ValueKind == JsonValueKind.String) + { + string? single = op.GetString(); + return string.IsNullOrEmpty(single) + ? DefaultOperations(kind, affordance) + : ImmutableArray.Create(single!); + } + if (op.ValueKind == JsonValueKind.Array) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (JsonElement item in op.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + string? value = item.GetString(); + if (!string.IsNullOrEmpty(value)) + { + builder.Add(value!); + } + } + } + return builder.Count == 0 ? DefaultOperations(kind, affordance) : builder.ToImmutable(); + } + } + return DefaultOperations(kind, affordance); + } + + private static ImmutableArray DefaultOperations( + WotAffordanceKind kind, JsonElement affordance) + { + switch (kind) + { + case WotAffordanceKind.Action: + return ImmutableArray.Create("invokeaction"); + case WotAffordanceKind.Event: + return ImmutableArray.Create("subscribeevent", "unsubscribeevent"); + default: + bool readOnly = GetBool(affordance, "readOnly"); + bool writeOnly = GetBool(affordance, "writeOnly"); + bool observable = GetBool(affordance, "observable"); + var builder = ImmutableArray.CreateBuilder(); + if (!writeOnly) + { + builder.Add("readproperty"); + } + if (!readOnly) + { + builder.Add("writeproperty"); + } + if (observable) + { + builder.Add("observeproperty"); + builder.Add("unobserveproperty"); + } + if (builder.Count == 0) + { + builder.Add("readproperty"); + } + return builder.ToImmutable(); + } + } + + private static ImmutableArray ReadSecurity(JsonElement element) + { + if (element.ValueKind != JsonValueKind.Object || + !element.TryGetProperty("security", out JsonElement security)) + { + return ImmutableArray.Empty; + } + if (security.ValueKind == JsonValueKind.String) + { + string? single = security.GetString(); + return string.IsNullOrEmpty(single) + ? ImmutableArray.Empty + : ImmutableArray.Create(single!); + } + if (security.ValueKind == JsonValueKind.Array) + { + var builder = ImmutableArray.CreateBuilder(); + foreach (JsonElement item in security.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + string? value = item.GetString(); + if (!string.IsNullOrEmpty(value)) + { + builder.Add(value!); + } + } + } + return builder.ToImmutable(); + } + return ImmutableArray.Empty; + } + + private static string? GetString(JsonElement element, string property) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(property, out JsonElement value) && + value.ValueKind == JsonValueKind.String + ? value.GetString() + : null; + } + + private static bool GetBool(JsonElement element, string property) + { + return element.ValueKind == JsonValueKind.Object && + element.TryGetProperty(property, out JsonElement value) && + value.ValueKind == JsonValueKind.True; + } + } +} diff --git a/src/Opc.Ua.WotCon.Binding/WotProtocolBinderRegistry.cs b/src/Opc.Ua.WotCon.Binding/WotProtocolBinderRegistry.cs new file mode 100644 index 0000000000..1515973478 --- /dev/null +++ b/src/Opc.Ua.WotCon.Binding/WotProtocolBinderRegistry.cs @@ -0,0 +1,297 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding +{ + /// + /// The concrete binder registry. It aggregates independently injected protocol + /// binders (planner + identification) and optional executors, performs + /// deterministic selection using pinned identification rules (not the URI + /// scheme alone), compiles forms into immutable plans, and drives the + /// Prepare / Activate / Deactivate lifecycle. Multiple versions of a binding + /// can coexist; the executor for a binder is matched by id so a protocol can be + /// validated without an executor and executed once one is registered. + /// + public sealed class WotProtocolBinderRegistry : IWotBinderRegistry + { + /// Initializes a new binder registry. + /// The protocol binders (planner + identification). + /// The optional runtime executors. + /// The credential provider used at activation time. + /// The codec registry used to select payload codecs. + /// The safety bounds enforced during planning. + public WotProtocolBinderRegistry( + IEnumerable binders, + IEnumerable? executors = null, + IWotCredentialProvider? credentials = null, + IWotCodecRegistry? codecs = null, + WotBindingBounds? bounds = null) + { + if (binders is null) + { + throw new ArgumentNullException(nameof(binders)); + } + m_credentials = credentials ?? NullWotCredentialProvider.Instance; + m_codecs = codecs ?? WotPayloadCodecRegistry.Default; + m_bounds = bounds ?? WotBindingBounds.Default; + + var seenBinderKeys = new HashSet(StringComparer.Ordinal); + foreach (IWotProtocolBinder binder in binders) + { + if (binder is null) + { + continue; + } + // Multiple versions coexist; the same id@version is deduplicated. + if (seenBinderKeys.Add(binder.Identity.Key)) + { + m_binders[binder.Identity.Key] = binder; + m_ordered.Add(binder); + } + } + // Deterministic evaluation order: ordinal by id@version. + m_ordered.Sort(static (a, b) => + string.CompareOrdinal(a.Identity.Key, b.Identity.Key)); + + if (executors is not null) + { + foreach (IWotBindingExecutor executor in executors) + { + if (executor is null) + { + continue; + } + m_executorsByKey[executor.Identity.Key] = executor; + // Last executor for an id wins as the id-level default. + m_executorsById[executor.Identity.Id] = executor; + } + } + + var capabilities = ImmutableArray.CreateBuilder(m_ordered.Count); + foreach (IWotProtocolBinder binder in m_ordered) + { + capabilities.Add(binder.Capability.ToDataType()); + } + Capabilities = capabilities.ToImmutable(); + } + + /// + public IReadOnlyList Capabilities { get; } + + /// Gets the registered binders in deterministic evaluation order. + public IReadOnlyList Binders => m_ordered; + + /// + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + if (request.Forms.IsEmpty) + { + return WotBindingPlan.Empty; + } + + WotBindingPlanContext context = request.CreateContext(m_codecs, m_bounds); + var compiled = ImmutableArray.CreateBuilder(); + var unsupported = ImmutableArray.CreateBuilder(); + var diagnostics = ImmutableArray.CreateBuilder(); + var participating = new Dictionary(StringComparer.Ordinal); + + foreach (WotAffordanceForm form in request.Forms) + { + IWotProtocolBinder? binder = Select(form, request.Selection); + if (binder is null) + { + unsupported.Add(form); + diagnostics.Add(WotBindingDiagnostic.Warning( + WotBindingDiagnosticCode.UnsupportedScheme, + $"No binder handles the '{form.AffordanceName}' form.", + form.Pointer("href"))); + continue; + } + + WotBindingCompilation compilation = binder.Planner.Compile(form, context); + diagnostics.AddRange(compilation.Diagnostics); + if (!compilation.IsSupported || compilation.HasErrors || compilation.Entries.IsEmpty) + { + unsupported.Add(form); + continue; + } + + participating[binder.Identity.Key] = binder.Capability.ToDataType(); + bool executorPresent = HasExecutor(binder.Identity); + foreach (WotCompiledForm entry in compilation.Entries) + { + bool effective = entry.IsExecutable && executorPresent; + compiled.Add(entry.WithExecutable(effective)); + if (!effective) + { + diagnostics.Add(WotBindingDiagnostic.Info( + WotBindingDiagnosticCode.NonExecutableBinding, + $"The binding '{binder.Identity.Id}' validated the '{form.AffordanceName}' " + + "form but no runtime executor is available; it is materialized as non-executable.", + entry.JsonPointer)); + } + } + } + + return new WotBindingPlan( + request.ResourceXid, + participating.Values.ToImmutableArray(), + compiled.ToImmutable(), + unsupported.ToImmutable(), + diagnostics.ToImmutable()); + } + + /// + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + if (plan is null) + { + throw new ArgumentNullException(nameof(plan)); + } + lock (m_activeLock) + { + m_activeResources.Add(plan.ResourceXid); + } + return default; + } + + /// + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + if (plan is null) + { + throw new ArgumentNullException(nameof(plan)); + } + lock (m_activeLock) + { + m_activeResources.Remove(plan.ResourceXid); + } + return default; + } + + /// Gets whether a resource's plan is currently activated. + public bool IsActive(string resourceXid) + { + lock (m_activeLock) + { + return m_activeResources.Contains(resourceXid); + } + } + + /// Attempts to resolve the executor registered for a binder identity. + public bool TryGetExecutor(WotBindingIdentity identity, out IWotBindingExecutor executor) + { + if (identity is null) + { + throw new ArgumentNullException(nameof(identity)); + } + if (m_executorsByKey.TryGetValue(identity.Key, out IWotBindingExecutor? exact) && exact is not null) + { + executor = exact; + return true; + } + if (m_executorsById.TryGetValue(identity.Id, out IWotBindingExecutor? byId) && byId is not null) + { + executor = byId; + return true; + } + executor = null!; + return false; + } + + /// + /// Opens a live channel for an executable compiled form using the + /// registry's credential provider, codecs and bounds. Used by the runtime + /// value adapter and by end-to-end tests. + /// + public ValueTask OpenChannelAsync( + WotCompiledForm form, CancellationToken cancellationToken = default) + { + if (form is null) + { + throw new ArgumentNullException(nameof(form)); + } + if (!TryGetExecutor(form.Binding, out IWotBindingExecutor executor)) + { + throw new InvalidOperationException( + $"No executor is registered for binding '{form.Binding.Key}'."); + } + var context = new WotExecutorContext(m_credentials, m_codecs, m_bounds); + return executor.ActivateAsync(form, context, cancellationToken); + } + + private IWotProtocolBinder? Select(WotAffordanceForm form, WotBindingSelectionContext selection) + { + IWotProtocolBinder? best = null; + WotBindingMatch bestMatch = WotBindingMatch.NoMatch; + foreach (IWotProtocolBinder binder in m_ordered) + { + WotBindingMatch match = binder.Identification.Match(form, selection); + if (!match.IsMatch) + { + continue; + } + // Higher priority wins; ties are broken by ordinal id@version, which + // is guaranteed because m_ordered is sorted and evaluated in order. + if (best is null || match.Priority > bestMatch.Priority) + { + best = binder; + bestMatch = match; + } + } + return best; + } + + private bool HasExecutor(WotBindingIdentity identity) + => m_executorsByKey.ContainsKey(identity.Key) || m_executorsById.ContainsKey(identity.Id); + + private readonly IWotCredentialProvider m_credentials; + private readonly IWotCodecRegistry m_codecs; + private readonly WotBindingBounds m_bounds; + private readonly Dictionary m_binders = + new Dictionary(StringComparer.Ordinal); + private readonly List m_ordered = new List(); + private readonly Dictionary m_executorsByKey = + new Dictionary(StringComparer.Ordinal); + private readonly Dictionary m_executorsById = + new Dictionary(StringComparer.Ordinal); + private readonly object m_activeLock = new object(); + private readonly HashSet m_activeResources = new HashSet(StringComparer.Ordinal); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs b/src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs index 69400c7418..8f04dc9aff 100644 --- a/src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs +++ b/src/Opc.Ua.WotCon.Server/Assets/AssetRegistry.cs @@ -237,6 +237,7 @@ public async ValueTask DeleteAssetAsync( await m_manager.DeleteAssetNodeAsync(entry.Asset, ct).ConfigureAwait(false); DeleteTdFromDisk(entry.Name); + await RemoveFromRegistryAsync(entry.Name, ct).ConfigureAwait(false); return ServiceResult.Good; } finally @@ -578,6 +579,8 @@ public async ValueTask RebuildAsync( { PersistTdToDisk(entry.Name, td); } + + await MirrorToRegistryAsync(entry.Name, td, ct).ConfigureAwait(false); } finally { @@ -641,7 +644,7 @@ private void BuildPropertyNode(AssetEntry entry, string name, WotProperty proper BrowseName = new QualifiedName(name, ns), DisplayName = new LocalizedText(property.Title ?? name), Description = property.Description != null ? new LocalizedText(property.Description) : LocalizedText.Null, - DataType = mapped ? dataType : DataTypeIds.BaseDataType, + DataType = mapped ? dataType : Ua.DataTypeIds.BaseDataType, ValueRank = mapped ? valueRank : ValueRanks.Scalar, AccessLevel = property.ReadOnly ? AccessLevels.CurrentRead : AccessLevels.CurrentReadOrWrite, UserAccessLevel = property.ReadOnly ? AccessLevels.CurrentRead : AccessLevels.CurrentReadOrWrite, @@ -721,7 +724,7 @@ private void BuildActionNode(AssetEntry entry, string name, WotAction action) inputProperty.NodeId = m_manager.AllocateChildNodeId(entry.Name, "actions", name + "_in"); inputProperty.BrowseName = new QualifiedName(Ua.BrowseNames.InputArguments); inputProperty.DisplayName = new LocalizedText(Ua.BrowseNames.InputArguments); - inputProperty.DataType = DataTypeIds.Argument; + inputProperty.DataType = Ua.DataTypeIds.Argument; inputProperty.ValueRank = ValueRanks.OneDimension; inputProperty.ReferenceTypeId = Ua.ReferenceTypeIds.HasProperty; inputProperty.TypeDefinitionId = VariableTypeIds.PropertyType; @@ -741,7 +744,7 @@ private void BuildActionNode(AssetEntry entry, string name, WotAction action) outputProperty.NodeId = m_manager.AllocateChildNodeId(entry.Name, "actions", name + "_out"); outputProperty.BrowseName = new QualifiedName(Ua.BrowseNames.OutputArguments); outputProperty.DisplayName = new LocalizedText(Ua.BrowseNames.OutputArguments); - outputProperty.DataType = DataTypeIds.Argument; + outputProperty.DataType = Ua.DataTypeIds.Argument; outputProperty.ValueRank = ValueRanks.OneDimension; outputProperty.ReferenceTypeId = Ua.ReferenceTypeIds.HasProperty; outputProperty.TypeDefinitionId = VariableTypeIds.PropertyType; @@ -902,6 +905,54 @@ private void DeleteTdFromDisk(string name) } } + private async ValueTask MirrorToRegistryAsync( + string name, ThingDescription td, CancellationToken ct) + { + Registry.IWotRegistryService? registry = m_options.RegistryBridge; + if (registry is null) + { + return; + } + try + { + byte[] bytes = JsonSerializer.SerializeToUtf8Bytes( + td, ThingDescriptionJsonContext.Default.ThingDescription); + await registry.UpsertResourceAsync(new Registry.WotUpsertResourceRequest + { + GroupId = m_options.RegistryBridgeGroupId, + ResourceId = name, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = bytes, + ContentType = "application/td+json", + Format = "WoT-TD/1.1", + Name = name + }, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.FailedToPersistTd(ex, name); + } + } + + private async ValueTask RemoveFromRegistryAsync(string name, CancellationToken ct) + { + Registry.IWotRegistryService? registry = m_options.RegistryBridge; + if (registry is null) + { + return; + } + try + { + await registry.DeleteResourceAsync( + m_options.RegistryBridgeGroupId, name, cancellationToken: ct) + .ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.FailedToDeleteTd(ex, name); + } + } + /// /// Enumerates persisted thing descriptions from the storage folder, /// loading and deserialising each one. diff --git a/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs new file mode 100644 index 0000000000..b75b63a25f --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Hosting/OpcUaWotRegistryServerBuilderExtensions.cs @@ -0,0 +1,158 @@ +/* ======================================================================== + * 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 Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using Opc.Ua; +using Opc.Ua.Server; +using Opc.Ua.Server.Hosting; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Microsoft.Extensions.DependencyInjection +{ + /// + /// extensions that host the WoT Connectivity 1.1 + /// registry (WoTRegistry) on the OPC UA server registered via + /// .AddServer(...). The registry service, materialization coordinator, + /// binder registry and projection host are registered as singletons; the + /// stable is attached at server start. + /// + public static class OpcUaWotRegistryServerBuilderExtensions + { + /// Default configuration section for the registry options. + public const string DefaultConfigurationSection = "OpcUa:WotConRegistry:Server"; + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager configured by + /// . + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + Action? configure = null) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (configure is not null) + { + builder.Services.AddOptions().Configure(configure); + } + else + { + builder.Services.AddOptions(); + } + RegisterCommonServices(builder.Services); + return builder; + } + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager with options + /// bound from the supplied configuration section. + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + IConfiguration configuration) + { + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + return builder.AddWotRegistryServer( + configuration.GetSection(DefaultConfigurationSection)); + } + + /// + /// Registers the WoT Connectivity 1.1 registry NodeManager with options + /// bound from the supplied configuration section. + /// + public static IOpcUaBuilder AddWotRegistryServer( + this IOpcUaBuilder builder, + IConfigurationSection section) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + if (section is null) + { + throw new ArgumentNullException(nameof(section)); + } + builder.Services.AddOptions().Bind(section); + RegisterCommonServices(builder.Services); + return builder; + } + + private static void RegisterCommonServices(IServiceCollection services) + { + services.TryAddSingleton(sp => + sp.GetRequiredService>().Value + ?? new WotRegistryServerOptions()); + + services.TryAddSingleton(NullWotBinderRegistry.Instance); + + services.TryAddSingleton(sp => + { + WotRegistryServerOptions options = + sp.GetRequiredService(); + IWotRegistryStore store = string.IsNullOrEmpty(options.StorageFolder) + ? new InMemoryWotRegistryStore() + : new FileWotRegistryStore(options.StorageFolder!); + return new WotRegistryService(store, options.Bounds); + }); + + services.TryAddSingleton(sp => + new LifecycleWotProjectionHost( + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + new WotMaterializationCoordinator( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.TryAddSingleton(sp => + new WotRegistryNodeManagerFactory( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService())); + + services.AddSingleton(sp => + new OpcUaServerNodeManagerRegistration( + sp.GetRequiredService())); + + services.AddOpcUa(); + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs new file mode 100644 index 0000000000..c3d8b14d10 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotDocumentConverter.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The result of converting one registry document to a NodeSet2 model. + /// + public sealed class WotConversionOutput + { + /// Initializes a successful or failed conversion output. + public WotConversionOutput( + UANodeSet? nodeSet, + ImmutableArray errors, + ExpandedNodeId? rootNodeId = null) + { + NodeSet = nodeSet; + Errors = errors.IsDefault ? ImmutableArray.Empty : errors; + RootNodeId = rootNodeId; + } + + /// Gets the produced NodeSet2, or null on failure. + public UANodeSet? NodeSet { get; } + + /// Gets the conversion error messages. + public ImmutableArray Errors { get; } + + /// + /// Gets the root node of the projection (the type a Thing Model + /// materializes or the top-level instance a Thing Description projects), + /// as an absolute whose namespace URI is + /// resolved from the produced NodeSet, or null when the document + /// has no identifiable root. + /// + public ExpandedNodeId? RootNodeId { get; } + + /// Gets whether the conversion succeeded. + public bool Succeeded => NodeSet is not null && Errors.IsEmpty; + + /// Creates a successful output. + public static WotConversionOutput Success(UANodeSet nodeSet) + => new WotConversionOutput( + nodeSet, + ImmutableArray.Empty, + WotNodeSetConverter.TrySelectProjectionRoot(nodeSet)); + + /// Creates a failed output. + public static WotConversionOutput Failure(params string[] errors) + => new WotConversionOutput(null, errors.ToImmutableArray()); + } + + /// + /// Converts a stored registry document to a NodeSet2 model. The default + /// implementation delegates to and resolves + /// TM references from the registry snapshot; a test double can substitute a + /// deterministic conversion. + /// + public interface IWotDocumentConverter + { + /// Converts a resource's default document to a NodeSet2 model. + WotConversionOutput Convert( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot); + } + + /// + /// The production converter over . + /// + public sealed class WotNodeSetDocumentConverter : IWotDocumentConverter + { + /// Initializes a new converter with the supplied options. + public WotNodeSetDocumentConverter(WotNodeSetConverterOptions? options = null) + { + m_options = options ?? new WotNodeSetConverterOptions(); + } + + /// + public WotConversionOutput Convert( + WotResource resource, + ReadOnlyMemory content, + WotRegistrySnapshot snapshot) + { + try + { + using WotDocument document = WotDocument.Parse(content, m_options); + var resolver = new SnapshotThingResolver(snapshot); + // One resolution context per top-level conversion, seeded from + // the configured converter options, so depth/document/byte + // bounds and cycle detection apply across every link resolved + // while converting this resource. + var resolution = new WotResolutionContext(m_options.ToResolverOptions()); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult( + document, m_options, resolver, resolution); + var errors = ImmutableArray.CreateBuilder(); + foreach (WotDiagnostic diagnostic in result.Diagnostics) + { + if (diagnostic.Severity == WotDiagnosticSeverity.Error) + { + errors.Add(diagnostic.ToString()); + } + } + if (result.Value is null && errors.Count == 0) + { + errors.Add("The document could not be converted to a NodeSet."); + } + if (errors.Count != 0 || result.Value is null) + { + return new WotConversionOutput(null, errors.ToImmutable()); + } + return new WotConversionOutput( + result.Value, + ImmutableArray.Empty, + WotNodeSetConverter.TrySelectProjectionRoot(result.Value)); + } + catch (Exception ex) when (ex is System.Text.Json.JsonException or FormatException) + { + return WotConversionOutput.Failure(ex.Message); + } + } + + private readonly WotNodeSetConverterOptions m_options; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs new file mode 100644 index 0000000000..a4eef12eb0 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/IWotProjectionHost.cs @@ -0,0 +1,199 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Selects how the previous projection generation is retired after a + /// successful replacement. + /// + public enum WotProjectionRetirementPolicy + { + /// + /// Keep the previous generation alive until its monitored items and + /// requests drain. + /// + Graceful, + + /// + /// Invalidate its monitored items with BadNodeIdUnknown and dispose the + /// previous generation without waiting for drain. + /// + Immediate + } + + /// + /// One NodeSet2 document loaded as a runtime NodeManager source. A projection + /// closure produces one or more of these (TM type NodeSets loaded before the + /// dependent TD instance NodeSet). + /// + public sealed class WotProjectionSource + { + /// Initializes a new projection source. + public WotProjectionSource( + string name, + ImmutableArray modelNamespaceUris, + byte[] nodeSetXml) + { + Name = name ?? throw new ArgumentNullException(nameof(name)); + ModelNamespaceUris = modelNamespaceUris.IsDefault + ? ImmutableArray.Empty : modelNamespaceUris; + NodeSetXml = nodeSetXml ?? throw new ArgumentNullException(nameof(nodeSetXml)); + } + + /// Gets the diagnostic source name. + public string Name { get; } + + /// Gets the model namespace URIs this source owns. + public ImmutableArray ModelNamespaceUris { get; } + + /// Gets the serialized NodeSet2 XML bytes. + public byte[] NodeSetXml { get; } + } + + /// + /// The full set of NodeSet2 sources for one projection closure to be added + /// or shadow-reloaded as a single runtime NodeManager. + /// + public sealed class WotProjectionDocument + { + /// Initializes a new projection document. + public WotProjectionDocument( + string closureKey, + ImmutableArray sources) + { + ClosureKey = closureKey ?? throw new ArgumentNullException(nameof(closureKey)); + Sources = sources.IsDefault ? ImmutableArray.Empty : sources; + } + + /// Gets the stable closure key this document projects. + public string ClosureKey { get; } + + /// Gets the ordered NodeSet2 sources. + public ImmutableArray Sources { get; } + } + + /// + /// An opaque handle to a live projection generation held by the host. It + /// wraps the underlying runtime NodeManager registration and records the + /// materialized root NodeIds and node count. + /// + public sealed class WotProjectionHandle + { + /// Initializes a new projection handle. + public WotProjectionHandle( + string closureKey, + long generation, + object? registration, + ImmutableArray rootNodeIds, + int materializedNodeCount, + string warning = "") + { + ClosureKey = closureKey ?? string.Empty; + Generation = generation; + Registration = registration; + RootNodeIds = rootNodeIds.IsDefault ? ImmutableArray.Empty : rootNodeIds; + MaterializedNodeCount = materializedNodeCount; + Warning = warning ?? string.Empty; + } + + /// Gets the closure key. + public string ClosureKey { get; } + + /// Gets the projection generation. + public long Generation { get; } + + /// Gets the underlying runtime registration (host-specific). + public object? Registration { get; } + + /// Gets the materialized root NodeIds. + public ImmutableArray RootNodeIds { get; } + + /// Gets the materialized node count. + public int MaterializedNodeCount { get; } + + /// + /// Gets a non-fatal host warning produced after the replacement generation + /// was committed, for example deferred cleanup of the previous generation. + /// + public string Warning { get; } + } + + /// + /// The seam between the materialization coordinator and the live server's + /// NodeManager lifecycle. The production implementation adds a runtime + /// NodeSet on first activation and shadow-reloads it on update, keeping the + /// stable registry NodeManager separate. A test double records the sequence + /// of add/shadow-reload/remove operations without a running server. + /// + public interface IWotProjectionHost + { + /// + /// Adds a projection for its first activation and returns a handle to the + /// new live generation. + /// + ValueTask AddAsync( + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Shadow-reloads a live projection: new service requests are routed to + /// the replacement generation while the previous generation keeps serving + /// its existing monitored items until they drain. + /// + ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Reloads a live projection and immediately retires the previous + /// generation. Affected data-change monitored items report + /// . + /// + ValueTask ImmediateReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default); + + /// + /// Removes a live projection after its monitored items drain, without + /// disconnecting clients. + /// + ValueTask RemoveAsync( + WotProjectionHandle handle, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs new file mode 100644 index 0000000000..6bf2ab4e38 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/LifecycleWotProjectionHost.cs @@ -0,0 +1,177 @@ +/* ======================================================================== + * 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.Immutable; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Server; +using Opc.Ua.Server.RuntimeNodeSet; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// The production that projects WoT closures + /// onto the live server through the public NodeManager lifecycle. First + /// activation uses + /// ; + /// updates use + /// , + /// so the previous generation keeps serving its existing monitored items + /// until they drain. The stable WoT registry NodeManager is never touched. + /// + public sealed class LifecycleWotProjectionHost : IWotProjectionHost + { + /// Initializes a new host over the supplied lifecycle. + public LifecycleWotProjectionHost(INodeManagerLifecycle lifecycle) + { + m_lifecycle = lifecycle ?? throw new ArgumentNullException(nameof(lifecycle)); + } + + /// + public async ValueTask AddAsync( + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration registration = await m_lifecycle + .AddRuntimeNodeSetAsync(options, cancellationToken) + .ConfigureAwait(false); + return new WotProjectionHandle( + document.ClosureKey, + registration.Generation, + registration, + ImmutableArray.Empty, + 0); + } + + /// + public async ValueTask ShadowReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (current?.Registration is not NodeManagerRegistration registration) + { + // No live registration to reload; fall back to a fresh add. + return await AddAsync(document, cancellationToken).ConfigureAwait(false); + } + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration next; + string warning = string.Empty; + try + { + next = await m_lifecycle + .ShadowReloadRuntimeNodeSetAsync(registration, options, cancellationToken) + .ConfigureAwait(false); + } + catch (NodeManagerReloadCommittedException ex) + { + next = ex.Registration; + warning = "The replacement is active, but prior-generation cleanup is pending: " + + ex.Message; + } + return new WotProjectionHandle( + document.ClosureKey, + next.Generation, + next, + ImmutableArray.Empty, + 0, + warning); + } + + /// + public async ValueTask ImmediateReloadAsync( + WotProjectionHandle current, + WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + if (current?.Registration is not NodeManagerRegistration registration) + { + return await AddAsync(document, cancellationToken).ConfigureAwait(false); + } + RuntimeNodeSetOptions options = BuildOptions(document); + NodeManagerRegistration next; + string warning = string.Empty; + try + { + next = await m_lifecycle + .ImmediateReloadRuntimeNodeSetAsync(registration, options, cancellationToken) + .ConfigureAwait(false); + } + catch (NodeManagerReloadCommittedException ex) + { + next = ex.Registration; + warning = "The replacement is active, but prior-generation cleanup is pending: " + + ex.Message; + } + return new WotProjectionHandle( + document.ClosureKey, + next.Generation, + next, + ImmutableArray.Empty, + 0, + warning); + } + + /// + public async ValueTask RemoveAsync( + WotProjectionHandle handle, + CancellationToken cancellationToken = default) + { + if (handle?.Registration is NodeManagerRegistration registration) + { + await m_lifecycle.RemoveAsync(registration, cancellationToken).ConfigureAwait(false); + } + } + + private static RuntimeNodeSetOptions BuildOptions(WotProjectionDocument document) + { + var sources = new RuntimeNodeSetSource[document.Sources.Length]; + for (int i = 0; i < document.Sources.Length; i++) + { + WotProjectionSource source = document.Sources[i]; + byte[] xml = source.NodeSetXml; + var uris = new ArrayOf(source.ModelNamespaceUris.ToArray()); + sources[i] = RuntimeNodeSetSource.FromStream( + source.Name, + _ => new ValueTask(new MemoryStream(xml, writable: false)), + uris); + } + return new RuntimeNodeSetOptions + { + Sources = new ArrayOf(sources) + }; + } + + private readonly INodeManagerLifecycle m_lifecycle; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs new file mode 100644 index 0000000000..2619c93106 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotDependencyGraph.cs @@ -0,0 +1,535 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Text.Json; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// One resolved (or unresolved) dependency edge between two documents. + /// + public sealed class WotDependency + { + /// Initializes a new dependency edge. + public WotDependency( + string sourceXid, + string targetHref, + string? targetXid, + string refType, + bool resolved) + { + SourceXid = sourceXid; + TargetHref = targetHref; + TargetXid = targetXid; + RefType = refType; + Resolved = resolved; + } + + /// Gets the xid of the dependent document. + public string SourceXid { get; } + + /// Gets the raw href/URI of the dependency. + public string TargetHref { get; } + + /// Gets the xid of the resolved target document, if any. + public string? TargetXid { get; } + + /// Gets the dependency kind (tm:extends / tm:ref / links.rel=type). + public string RefType { get; } + + /// Gets whether the dependency resolved to a stored document. + public bool Resolved { get; } + } + + /// + /// A dependency closure: a set of resources that must be materialized + /// together, with Thing Models topologically ordered before the Thing + /// Descriptions that depend on them. A closure is the default unit of + /// atomicity for a refresh. + /// + public sealed class WotDependencyClosure + { + internal WotDependencyClosure( + string key, + ImmutableArray members, + ImmutableArray orderedResources, + ImmutableArray dependencies, + ImmutableArray diagnostics, + bool hasCycle, + bool hasMissingDependency) + { + Key = key; + Members = members; + OrderedResources = orderedResources; + Dependencies = dependencies; + Diagnostics = diagnostics; + HasCycle = hasCycle; + HasMissingDependency = hasMissingDependency; + } + + /// Gets the stable closure key (sorted member xids). + public string Key { get; } + + /// Gets every member of the closure (populated even on a cycle). + public ImmutableArray Members { get; } + + /// Gets the resources in topological (dependency-first) order. + public ImmutableArray OrderedResources { get; } + + /// Gets the dependency edges within the closure. + public ImmutableArray Dependencies { get; } + + /// Gets the diagnostics for the closure. + public ImmutableArray Diagnostics { get; } + + /// Gets whether the closure contains a dependency cycle. + public bool HasCycle { get; } + + /// Gets whether the closure has an unresolved dependency. + public bool HasMissingDependency { get; } + + /// Gets whether the closure is projectable (no cycle, no missing dependency). + public bool IsProjectable => !HasCycle && !HasMissingDependency; + } + + /// + /// Builds the TD/TM dependency graph from a registry snapshot and partitions + /// it into deterministic dependency closures. References are extracted from + /// links (rel = tm:extends / type / tm:submodel), a top-level + /// tm:extends, and tm:ref pointers, then resolved against the + /// registry by Thing id, xid, or resource id. + /// + public static class WotDependencyGraph + { + /// + /// Resolves a WoT reference href to a stored resource, or null. + /// + public static WotResource? Resolve(WotRegistrySnapshot snapshot, string href) + { + if (snapshot is null || string.IsNullOrWhiteSpace(href)) + { + return null; + } + string trimmed = TrimFragment(href); + // Prefer Thing Models, then any resource, matching by thing id, xid or resource id. + return MatchIn(snapshot.ResourcesOfKind(WoTDocumentKindEnum.ThingModel), trimmed) + ?? MatchIn(snapshot.AllResources(), trimmed); + } + + /// + /// Extracts the outgoing dependency references of a single document. + /// + public static IReadOnlyList<(string Href, string RefType)> ExtractReferences( + ReadOnlyMemory document, + int maxJsonDepth) + { + var references = new List<(string, string)>(); + try + { + var options = new JsonDocumentOptions { MaxDepth = maxJsonDepth }; + using JsonDocument json = JsonDocument.Parse(document, options); + JsonElement root = json.RootElement; + if (root.ValueKind != JsonValueKind.Object) + { + return references; + } + CollectLinks(root, references); + CollectExtends(root, references); + CollectTmRefs(root, references, 0, maxJsonDepth); + } + catch (JsonException) + { + // A document that cannot be parsed contributes no edges; its own + // projection reports the parse failure. + } + return references; + } + + /// + /// Builds the dependency closures for the selected resources. Selected + /// resources are grouped into weakly-connected components (so a shared + /// Thing Model lands in a single closure), then each component is + /// topologically ordered. + /// + public static ImmutableArray BuildClosures( + WotRegistrySnapshot snapshot, + IReadOnlyCollection selected, + int maxJsonDepth) + { + if (selected.Count == 0) + { + return ImmutableArray.Empty; + } + + // Expand the selection to include resolvable transitive dependencies. + var byXid = new Dictionary(StringComparer.Ordinal); + var queue = new Queue(); + foreach (WotResource resource in selected) + { + if (!byXid.ContainsKey(resource.Xid)) + { + byXid[resource.Xid] = resource; + queue.Enqueue(resource); + } + } + + var edges = new Dictionary>(StringComparer.Ordinal); + while (queue.Count > 0) + { + WotResource resource = queue.Dequeue(); + var list = new List(); + edges[resource.Xid] = list; + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + continue; + } + foreach ((string href, string refType) in ExtractReferences( + version.Content, maxJsonDepth)) + { + WotResource? target = Resolve(snapshot, href); + list.Add(new WotDependency( + resource.Xid, href, target?.Xid, refType, target is not null)); + if (target is not null && !byXid.ContainsKey(target.Xid)) + { + byXid[target.Xid] = target; + queue.Enqueue(target); + } + } + } + + // Weakly-connected components via union-find over resolved edges. + var parent = new Dictionary(StringComparer.Ordinal); + foreach (string xid in byXid.Keys) + { + parent[xid] = xid; + } + foreach (List list in edges.Values) + { + foreach (WotDependency edge in list) + { + if (edge.Resolved && edge.TargetXid is not null && + byXid.ContainsKey(edge.TargetXid)) + { + Union(parent, edge.SourceXid, edge.TargetXid); + } + } + } + + var components = new Dictionary>(StringComparer.Ordinal); + foreach (KeyValuePair entry in byXid) + { + string root = Find(parent, entry.Key); + if (!components.TryGetValue(root, out List? members)) + { + members = new List(); + components[root] = members; + } + members.Add(entry.Value); + } + + var closures = ImmutableArray.CreateBuilder(); + foreach (List members in components.Values) + { + closures.Add(BuildClosure(members, edges, byXid)); + } + // Deterministic order by closure key. + return closures + .OrderBy(c => c.Key, StringComparer.Ordinal) + .ToImmutableArray(); + } + + private static WotDependencyClosure BuildClosure( + List members, + Dictionary> edges, + Dictionary byXid) + { + var memberXids = new HashSet(members.Select(m => m.Xid), StringComparer.Ordinal); + var dependencies = ImmutableArray.CreateBuilder(); + var diagnostics = ImmutableArray.CreateBuilder(); + bool missing = false; + + // Adjacency (source depends on target): target must be ordered first. + var adjacency = new Dictionary>(StringComparer.Ordinal); + foreach (WotResource member in members) + { + adjacency[member.Xid] = new List(); + } + foreach (WotResource member in members) + { + if (!edges.TryGetValue(member.Xid, out List? list)) + { + continue; + } + foreach (WotDependency edge in list) + { + dependencies.Add(edge); + if (!edge.Resolved) + { + missing = true; + diagnostics.Add( + $"Unresolved {edge.RefType} dependency '{edge.TargetHref}' " + + $"referenced by '{edge.SourceXid}'."); + } + else if (edge.TargetXid is not null && memberXids.Contains(edge.TargetXid)) + { + adjacency[member.Xid].Add(edge.TargetXid); + } + } + } + + (ImmutableArray ordered, bool hasCycle) = TopologicalSort( + members, adjacency, byXid); + if (hasCycle) + { + diagnostics.Add( + "Dependency cycle detected among: " + + string.Join(", ", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal))); + } + + string key = string.Join( + "|", members.Select(m => m.Xid).OrderBy(x => x, StringComparer.Ordinal)); + ImmutableArray memberArray = members + .OrderBy(m => m.Xid, StringComparer.Ordinal) + .ToImmutableArray(); + return new WotDependencyClosure( + key, + memberArray, + ordered, + dependencies.ToImmutable(), + diagnostics.ToImmutable(), + hasCycle, + missing); + } + + private static (ImmutableArray Ordered, bool HasCycle) TopologicalSort( + List members, + Dictionary> adjacency, + Dictionary byXid) + { + // 0 = unvisited, 1 = in-progress, 2 = done. + var color = new Dictionary(StringComparer.Ordinal); + var ordered = new List(); + bool hasCycle = false; + + // Deterministic iteration order. + IEnumerable roots = members + .Select(m => m.Xid) + .OrderBy(x => x, StringComparer.Ordinal); + + void Visit(string xid) + { + if (hasCycle) + { + return; + } + color.TryGetValue(xid, out int state); + if (state == 2) + { + return; + } + if (state == 1) + { + hasCycle = true; + return; + } + color[xid] = 1; + foreach (string dependency in adjacency[xid] + .OrderBy(x => x, StringComparer.Ordinal)) + { + Visit(dependency); + if (hasCycle) + { + return; + } + } + color[xid] = 2; + ordered.Add(byXid[xid]); + } + + foreach (string root in roots) + { + Visit(root); + } + + return hasCycle + ? (ImmutableArray.Empty, true) + : (ordered.ToImmutableArray(), false); + } + + private static WotResource? MatchIn(IEnumerable resources, string href) + { + foreach (WotResource resource in resources) + { + if (string.Equals(resource.ThingId, href, StringComparison.Ordinal) || + string.Equals(resource.Xid, href, StringComparison.Ordinal) || + string.Equals(RegistryUri(resource), href, StringComparison.Ordinal) || + string.Equals(resource.ResourceId, href, StringComparison.Ordinal) || + href.EndsWith("/" + resource.ResourceId, StringComparison.Ordinal)) + { + return resource; + } + } + return null; + } + + private static string RegistryUri(WotResource resource) + => $"urn:wot:{resource.GroupId}/{resource.ResourceId}"; + + private static string TrimFragment(string href) + { + int hash = href.AsSpan().IndexOf('#'); + return hash >= 0 ? href.Substring(0, hash) : href; + } + + private static void CollectLinks( + JsonElement root, List<(string, string)> references) + { + if (!root.TryGetProperty("links", out JsonElement links) || + links.ValueKind != JsonValueKind.Array) + { + return; + } + foreach (JsonElement link in links.EnumerateArray()) + { + if (link.ValueKind != JsonValueKind.Object || + !link.TryGetProperty("href", out JsonElement hrefElement) || + hrefElement.ValueKind != JsonValueKind.String) + { + continue; + } + string rel = link.TryGetProperty("rel", out JsonElement relElement) && + relElement.ValueKind == JsonValueKind.String + ? relElement.GetString() ?? string.Empty + : string.Empty; + if (rel is "tm:extends" or "type" or "tm:submodel" or "collection" or "item") + { + references.Add((hrefElement.GetString() ?? string.Empty, rel)); + } + } + } + + private static void CollectExtends( + JsonElement root, List<(string, string)> references) + { + if (!root.TryGetProperty("tm:extends", out JsonElement extends)) + { + return; + } + switch (extends.ValueKind) + { + case JsonValueKind.String: + references.Add((extends.GetString() ?? string.Empty, "tm:extends")); + break; + case JsonValueKind.Array: + foreach (JsonElement item in extends.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.String) + { + references.Add((item.GetString() ?? string.Empty, "tm:extends")); + } + else if (item.ValueKind == JsonValueKind.Object && + item.TryGetProperty("href", out JsonElement href) && + href.ValueKind == JsonValueKind.String) + { + references.Add((href.GetString() ?? string.Empty, "tm:extends")); + } + } + break; + } + } + + private static void CollectTmRefs( + JsonElement element, + List<(string, string)> references, + int depth, + int maxDepth) + { + if (depth > maxDepth) + { + return; + } + switch (element.ValueKind) + { + case JsonValueKind.Object: + foreach (JsonProperty property in element.EnumerateObject()) + { + if (string.Equals(property.Name, "tm:ref", StringComparison.Ordinal) && + property.Value.ValueKind == JsonValueKind.String) + { + references.Add((property.Value.GetString() ?? string.Empty, "tm:ref")); + } + else + { + CollectTmRefs(property.Value, references, depth + 1, maxDepth); + } + } + break; + case JsonValueKind.Array: + foreach (JsonElement item in element.EnumerateArray()) + { + CollectTmRefs(item, references, depth + 1, maxDepth); + } + break; + } + } + + private static string Find(Dictionary parent, string node) + { + string root = node; + while (!string.Equals(parent[root], root, StringComparison.Ordinal)) + { + root = parent[root]; + } + // Path compression. + while (!string.Equals(parent[node], root, StringComparison.Ordinal)) + { + string next = parent[node]; + parent[node] = root; + node = next; + } + return root; + } + + private static void Union(Dictionary parent, string a, string b) + { + string rootA = Find(parent, a); + string rootB = Find(parent, b); + if (!string.Equals(rootA, rootB, StringComparison.Ordinal)) + { + parent[rootB] = rootA; + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs new file mode 100644 index 0000000000..fb175d5097 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationCoordinator.cs @@ -0,0 +1,937 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Coordinates projecting registry documents into the AddressSpace. It parses + /// and validates each document with , builds the TD/TM + /// dependency closures, converts each closure to one or more NodeSet2 + /// documents and projects them through the + /// (runtime NodeSet Add for first activation, ShadowReload for updates). The + /// stable registry NodeManager is kept separate. Independent closures commit + /// independently; a failed or invalid closure retains its previous active + /// generation. An unchanged closure (same digest, options and binder version) + /// returns and emits no model change. + /// + public sealed class WotMaterializationCoordinator : IDisposable + { + /// Initializes a new coordinator. + public WotMaterializationCoordinator( + IWotRegistryService registry, + IWotProjectionHost projectionHost, + IWotBinderRegistry? binderRegistry = null, + WotNodeSetConverterOptions? converterOptions = null, + IWotDocumentConverter? documentConverter = null) + { + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_host = projectionHost ?? throw new ArgumentNullException(nameof(projectionHost)); + m_binders = binderRegistry ?? NullWotBinderRegistry.Instance; + m_converterOptions = converterOptions ?? new WotNodeSetConverterOptions(); + m_converter = documentConverter + ?? new WotNodeSetDocumentConverter(m_converterOptions); + } + + /// Raised for each materialization event (resource / validation / load / refresh). + public event EventHandler? Event; + + /// Gets the current refresh generation. + public uint Generation => m_generation; + + /// + /// Refreshes (re-projects) the registry into the AddressSpace and returns + /// the detailed result. + /// + public async ValueTask RefreshAsync( + WotRefreshRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + DateTime start = DateTime.UtcNow; + WotRegistrySnapshot snapshot = m_registry.Current; + + if (request.ExpectedGeneration != 0 && + request.ExpectedGeneration != (uint)snapshot.Generation) + { + return RejectedResult(request, snapshot, start); + } + + bool dryRun = request.Options?.DryRun ?? false; + bool force = request.Options?.Force ?? false; + bool strict = StrictBindings; + var selectedXids = ResolveSelection(snapshot, request.Selection); + + var enabled = snapshot.AllResources() + .Where(r => r.Enabled && r.DefaultVersion is not null) + .ToList(); + ImmutableArray closures = + WotDependencyGraph.BuildClosures( + snapshot, enabled, m_converterOptions.MaxJsonDepth); + + var targetKeys = new HashSet( + closures.Select(c => c.Key), StringComparer.Ordinal); + + uint newGeneration = ++m_generation; + var results = ImmutableArray.CreateBuilder(); + var projections = new List(); + int succeeded = 0, unchanged = 0, failed = 0, skipped = 0, retired = 0; + + // Retire tracked closures no longer desired (deleted / disabled / + // membership changed) after their monitored items drain. + retired += await ReconcileRetirementsAsync( + targetKeys, cancellationToken).ConfigureAwait(false); + + foreach (WotDependencyClosure closure in closures) + { + cancellationToken.ThrowIfCancellationRequested(); + bool inScope = selectedXids.Count == 0 || + closure.OrderedResources.Any(r => selectedXids.Contains(r.Xid)) || + MembersOf(closure).Any(r => selectedXids.Contains(r.Xid)); + + ClosureOutcome outcome = await ProcessClosureAsync( + snapshot, closure, newGeneration, force && inScope, + dryRun, strict, cancellationToken).ConfigureAwait(false); + + foreach (WoTResourceLoadResultDataType result in outcome.Results) + { + string resultXid = result.Xid ?? string.Empty; + if (selectedXids.Count != 0 && !selectedXids.Contains(resultXid)) + { + continue; + } + results.Add(result); + switch (result.Outcome) + { + case WoTOutcomeEnum.Success: + case WoTOutcomeEnum.Warning: + succeeded++; + break; + case WoTOutcomeEnum.Unchanged: + unchanged++; + break; + case WoTOutcomeEnum.Skipped: + skipped++; + break; + default: + failed++; + break; + } + } + projections.AddRange(outcome.Projections); + } + + if (!dryRun && projections.Count > 0) + { + await m_registry.ApplyProjectionResultsAsync( + projections, cancellationToken).ConfigureAwait(false); + } + + WoTOutcomeEnum overall = failed > 0 + ? (succeeded > 0 ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Failed) + : (succeeded > 0 ? WoTOutcomeEnum.Success : WoTOutcomeEnum.Unchanged); + + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = dryRun ? 0 : newGeneration, + Outcome = overall, + Atomicity = request.Options?.Atomicity ?? WoTAtomicityEnum.PerClosure, + StartTime = start, + EndTime = DateTime.UtcNow, + Total = (uint)results.Count, + Succeeded = (uint)succeeded, + Unchanged = (uint)unchanged, + Failed = (uint)failed, + Skipped = (uint)skipped, + Retired = (uint)retired + }; + + RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.RefreshCompleted) + { + Generation = newGeneration, + RequestId = request.RequestId ?? string.Empty, + Outcome = overall, + Summary = summary + }); + + return new WotRefreshResult( + summary, results.ToImmutable(), dryRun ? 0u : newGeneration); + } + finally + { + m_mutex.Release(); + } + } + + /// + /// Removes all live projections (used during NodeManager shutdown). + /// + public async ValueTask RemoveAllAsync(CancellationToken cancellationToken = default) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + foreach (ClosureState state in m_closures.Values) + { + // Deactivate bindings before removing the projection (before + // retirement / unload), then release the projection handle. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + if (state.Handle is not null) + { + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + } + } + m_closures.Clear(); + } + finally + { + m_mutex.Release(); + } + } + + /// Gets or sets whether unsupported forms fail a strict closure. + public bool StrictBindings { get; set; } + + /// + /// Gets or sets how previous projection generations are retired after a + /// successful version switch. + /// + public WotProjectionRetirementPolicy RetirementPolicy { get; set; } = + WotProjectionRetirementPolicy.Graceful; + + /// + /// Gets the binding capability snapshots advertised by the registered + /// binders. These populate the registry SelectedBindings node and + /// contribute to refresh unchanged-detection. + /// + public IReadOnlyList BindingCapabilities => m_binders.Capabilities; + + /// + /// Gets or sets the live server namespace table used to resolve a + /// projection's recorded root into a + /// concrete server NodeId after its owning namespace is registered by + /// the projection host. When null, materialized root NodeIds are + /// not reported. + /// + public NamespaceTable? ServerNamespaceUris { get; set; } + + /// Releases the mutex used to serialise refreshes. + public void Dispose() + { + m_mutex.Dispose(); + } + + private static ByteString DigestOf(WotResource resource) + => (ByteString)(resource.DefaultVersion?.Digest ?? Array.Empty()); + + private async ValueTask ProcessClosureAsync( + WotRegistrySnapshot snapshot, + WotDependencyClosure closure, + uint generation, + bool force, + bool dryRun, + bool strict, + CancellationToken cancellationToken) + { + var results = ImmutableArray.CreateBuilder(); + var projections = new List(); + IReadOnlyList members = MembersOf(closure); + + // Unprojectable closure: cycle or missing dependency. Retain the + // previous active generation and mark members failed. + if (!closure.IsProjectable) + { + WoTPhaseEnum phase = closure.HasMissingDependency + ? WoTPhaseEnum.DependencyResolution + : WoTPhaseEnum.DependencyResolution; + string reason = string.Join("; ", closure.Diagnostics); + foreach (WotResource member in members) + { + results.Add(FailResult(member, generation, phase, reason)); + projections.Add(FailProjection(member, reason)); + RaiseLoadFailure(member, generation, reason); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + // Project in topological (dependency-first) order. + members = closure.OrderedResources; + + byte[] aggregateDigest = ComputeAggregateDigest(members); + m_closures.TryGetValue(closure.Key, out ClosureState? tracked); + + // Unchanged: same digest/options/binder version, and not forced. + if (tracked?.Handle is not null && + !force && + WotContentDigest.Equal(tracked.AggregateDigest, aggregateDigest)) + { + foreach (WotResource member in members) + { + results.Add(UnchangedResult(member, tracked.Generation)); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + // Convert every member to a NodeSet2 source in dependency order. + var sources = ImmutableArray.CreateBuilder(); + var perMemberNodeCount = new Dictionary(StringComparer.Ordinal); + var perMemberRoot = new Dictionary(StringComparer.Ordinal); + var bindingPlans = new List(); + bool degraded = false; + + foreach (WotResource member in members) + { + WotResourceVersion? version = member.DefaultVersion; + if (version is null) + { + string reason = "Resource has no default version."; + results.Add(FailResult(member, generation, WoTPhaseEnum.Fetch, reason)); + projections.Add(FailProjection(member, reason)); + RaiseLoadFailure(member, generation, reason); + return new ClosureOutcome(results.ToImmutable(), projections); + } + + (UANodeSet? nodeSet, ExpandedNodeId? root, string? conversionError) = + TryConvert(member, snapshot); + if (nodeSet is null) + { + WoTValidationOutcomeDataType validation = FormatFailure(conversionError); + results.Add(FailResult( + member, generation, WoTPhaseEnum.FormatValidation, conversionError)); + projections.Add(FailProjection(member, conversionError, validation)); + RaiseValidationFailure(member, generation, validation, conversionError); + return new ClosureOutcome(results.ToImmutable(), projections); + } + + WotBindingPlan plan = m_binders.Prepare(BuildPlanRequest(member, version)); + bindingPlans.Add(plan); + if (!plan.FullySupported) + { + if (strict) + { + string reason = "Unsupported binding forms in a strict closure."; + results.Add(FailResult( + member, generation, WoTPhaseEnum.Projection, reason)); + projections.Add(FailProjection(member, reason)); + RaiseBindingFailure(member, reason); + return new ClosureOutcome(results.ToImmutable(), projections); + } + degraded = true; + RaiseBindingFailure(member, + "Unsupported binding forms materialized as degraded nodes."); + } + else if (plan.HasNonExecutableForms) + { + // A validated plan whose binding has no runtime executor (for + // example a planner-only protocol): materialize the nodes but + // flag the closure as degraded so callers know they cannot be + // driven yet. + degraded = true; + } + + byte[] xml = SerializeNodeSet(nodeSet); + perMemberNodeCount[member.Xid] = nodeSet.Items?.Length ?? 0; + if (root is { } rootId) + { + perMemberRoot[member.Xid] = rootId; + } + sources.Add(new WotProjectionSource( + member.ResourceId, OwnedModelUris(nodeSet), xml)); + } + + if (dryRun) + { + foreach (WotResource member in members) + { + results.Add(new WoTResourceLoadResultDataType + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.DefaultVersionId ?? string.Empty, + Kind = member.Kind, + Outcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success, + Phase = WoTPhaseEnum.Projection, + LoadState = member.LoadState, + Generation = 0, + MaterializedNodeCount = (uint)(perMemberNodeCount.TryGetValue( + member.Xid, out int c) ? c : 0), + ContentDigest = DigestOf(member), + Message = "Dry run; no projection committed." + }); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + var document = new WotProjectionDocument(closure.Key, sources.ToImmutable()); + WotProjectionHandle handle; + try + { + if (tracked?.Handle is null) + { + handle = await m_host.AddAsync(document, cancellationToken) + .ConfigureAwait(false); + } + else if (RetirementPolicy == WotProjectionRetirementPolicy.Immediate) + { + handle = await m_host.ImmediateReloadAsync( + tracked.Handle, document, cancellationToken).ConfigureAwait(false); + } + else + { + handle = await m_host.ShadowReloadAsync( + tracked.Handle, document, cancellationToken).ConfigureAwait(false); + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Projection failed: retain the previous active generation and its + // tracked binding plans. The shadow switch never happened, so the + // old plans remain active and no deactivation is performed + // (rollback: old plans survive when the new switch fails). + foreach (WotResource member in members) + { + results.Add(FailResult( + member, generation, WoTPhaseEnum.Activation, ex.Message)); + projections.Add(FailProjection(member, ex.Message)); + RaiseLoadFailure(member, generation, ex.Message); + } + return new ClosureOutcome(results.ToImmutable(), projections); + } + + string projectionWarning = handle.Warning; + if (projectionWarning.Length != 0) + { + degraded = true; + } + + // The shadow switch (or first add) succeeded. On an update, retire the + // previously tracked binding plans before publishing the new closure + // state so they are not leaked. This runs after the successful switch + // and before the closure state is replaced; deactivating the old plans + // first (then activating the new plans below) keeps a resource that is + // shared between the old and new plan sets continuously bound. + if (tracked is not null) + { + foreach (WotBindingPlan plan in tracked.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken).ConfigureAwait(false); + } + } + + m_closures[closure.Key] = new ClosureState + { + Key = closure.Key, + Handle = handle, + AggregateDigest = aggregateDigest, + Generation = generation, + MemberXids = members.Select(m => m.Xid).ToImmutableArray(), + BindingPlans = bindingPlans.ToImmutableArray() + }; + + foreach (WotBindingPlan plan in bindingPlans) + { + await m_binders.ActivateAsync(plan, cancellationToken).ConfigureAwait(false); + } + + WoTOutcomeEnum memberOutcome = degraded ? WoTOutcomeEnum.Warning : WoTOutcomeEnum.Success; + foreach (WotResource member in members) + { + int nodeCount = perMemberNodeCount.TryGetValue(member.Xid, out int c) ? c : 0; + NodeId? rootNodeId = perMemberRoot.TryGetValue(member.Xid, out ExpandedNodeId root) + ? ResolveRootNodeId(root) + : null; + WoTValidationOutcomeDataType validation = SuccessValidation(); + results.Add(new WoTResourceLoadResultDataType + { + Xid = member.Xid, + GroupId = member.GroupId, + ResourceId = member.ResourceId, + VersionId = member.DefaultVersionId ?? string.Empty, + Kind = member.Kind, + Outcome = memberOutcome, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Active, + Generation = generation, + MaterializedNodeCount = (uint)nodeCount, + RootNodeId = rootNodeId ?? NodeId.Null, + ContentDigest = DigestOf(member), + Message = projectionWarning.Length != 0 + ? "Projected with warning: " + projectionWarning + : degraded ? "Projected with degraded bindings." : "Projected." + }); + projections.Add(new WotResourceProjection( + member.GroupId, + member.ResourceId, + WoTLoadStateEnum.Active, + member.DefaultVersionId, + generation, + nodeCount, + rootNodeId, + validation, + projectionWarning.Length == 0 + ? ImmutableArray.Empty + : ImmutableArray.Create(projectionWarning), + DateTime.UtcNow)); + RaiseResource(member, generation, memberOutcome, WoTLoadStateEnum.Active); + } + + return new ClosureOutcome(results.ToImmutable(), projections); + } + + private async ValueTask ReconcileRetirementsAsync( + HashSet targetKeys, + CancellationToken cancellationToken) + { + int retired = 0; + List stale = m_closures.Keys + .Where(k => !targetKeys.Contains(k)) + .ToList(); + foreach (string key in stale) + { + if (m_closures.TryGetValue(key, out ClosureState? state)) + { + if (state.Handle is not null) + { + // Deactivate bindings before retiring the projection. + foreach (WotBindingPlan plan in state.BindingPlans) + { + await m_binders.DeactivateAsync(plan, cancellationToken) + .ConfigureAwait(false); + } + await m_host.RemoveAsync(state.Handle, cancellationToken) + .ConfigureAwait(false); + retired++; + } + m_closures.Remove(key); + } + } + return retired; + } + + private (UANodeSet? NodeSet, ExpandedNodeId? Root, string? Error) TryConvert( + WotResource resource, WotRegistrySnapshot snapshot) + { + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + return (null, null, "Resource has no default version."); + } + WotConversionOutput output = m_converter.Convert(resource, version.Content, snapshot); + if (!output.Succeeded) + { + return (null, null, output.Errors.IsDefaultOrEmpty + ? "The document could not be converted to a NodeSet." + : string.Join("; ", output.Errors)); + } + return (output.NodeSet, output.RootNodeId, null); + } + + /// + /// Resolves a projection root, recorded before lifecycle add as an + /// absolute , into a concrete server NodeId + /// once its owning namespace has been registered by the projection host. + /// Returns null when there is no root or the namespace table is + /// unavailable or does not yet contain the owning namespace. + /// + private NodeId? ResolveRootNodeId(ExpandedNodeId? root) + { + if (root is not { } value || value.IsNull) + { + return null; + } + NamespaceTable? namespaces = ServerNamespaceUris; + if (namespaces is null) + { + return null; + } + NodeId resolved = ExpandedNodeId.ToNodeId(value, namespaces); + return resolved.IsNull ? null : resolved; + } + + private WotBindingPlanRequest BuildPlanRequest( + WotResource resource, WotResourceVersion version) + { + return WotBindingPlanRequest.FromDocument( + resource.Xid, resource.Kind, version.Content, m_converterOptions.MaxJsonDepth); + } + + private byte[] ComputeAggregateDigest(IReadOnlyList members) + { + using var sha = SHA256.Create(); + using var buffer = new MemoryStream(); + using (var writer = new BinaryWriter(buffer, Encoding.UTF8, leaveOpen: true)) + { + foreach (WotResource member in members + .OrderBy(m => m.Xid, StringComparer.Ordinal)) + { + writer.Write(member.Xid); + writer.Write(member.DefaultVersionId ?? string.Empty); + byte[] digest = member.DefaultVersion?.Digest ?? Array.Empty(); + writer.Write(digest.Length); + writer.Write(digest); + } + writer.Write(m_converterOptions.MaxJsonDepth); + writer.Write(BinderVersion); + } + buffer.Position = 0; + return sha.ComputeHash(buffer.ToArray()); + } + + private string BinderVersion + { + get + { + IReadOnlyList caps = m_binders.Capabilities; + if (caps.Count == 0) + { + return "none"; + } + var builder = new StringBuilder(); + foreach (WoTBindingCapabilityDataType cap in caps) + { + builder.Append(cap.BindingUri).Append(';').Append(cap.ProfileVersion).Append('|'); + } + return builder.ToString(); + } + } + + private static byte[] SerializeNodeSet(UANodeSet nodeSet) + { + using var stream = new MemoryStream(); + nodeSet.Write(stream); + return stream.ToArray(); + } + + private static ImmutableArray OwnedModelUris(UANodeSet nodeSet) + { + if (nodeSet.Models is { Length: > 0 }) + { + var uris = new List(nodeSet.Models.Length); + foreach (ModelTableEntry model in nodeSet.Models) + { + if (!string.IsNullOrEmpty(model.ModelUri)) + { + uris.Add(model.ModelUri); + } + } + if (uris.Count > 0) + { + return uris.ToImmutableArray(); + } + } + if (nodeSet.NamespaceUris is { Length: > 0 }) + { + return nodeSet.NamespaceUris + .Where(u => !string.Equals(u, Opc.Ua.Namespaces.OpcUa, StringComparison.Ordinal)) + .ToImmutableArray(); + } + return ImmutableArray.Empty; + } + + private static IReadOnlyList MembersOf(WotDependencyClosure closure) + { + return closure.Members.IsDefaultOrEmpty + ? Array.Empty() + : (IReadOnlyList)closure.Members; + } + + private HashSet ResolveSelection( + WotRegistrySnapshot snapshot, + ImmutableArray selectors) + { + var set = new HashSet(StringComparer.Ordinal); + if (selectors.IsDefaultOrEmpty) + { + return set; + } + foreach (WoTResourceSelectorDataType selector in selectors) + { + foreach (WotResource resource in snapshot.AllResources()) + { + if (Matches(resource, selector)) + { + set.Add(resource.Xid); + } + } + } + return set; + } + + private static bool Matches(WotResource resource, WoTResourceSelectorDataType selector) + { + if (!string.IsNullOrEmpty(selector.Xid) && + !string.Equals(selector.Xid, resource.Xid, StringComparison.Ordinal)) + { + return false; + } + if (!string.IsNullOrEmpty(selector.GroupId) && + !string.Equals(selector.GroupId, resource.GroupId, StringComparison.Ordinal)) + { + return false; + } + if (!string.IsNullOrEmpty(selector.ResourceId) && + !string.Equals(selector.ResourceId, resource.ResourceId, StringComparison.Ordinal)) + { + return false; + } + return true; + } + + private static string? FirstError(IReadOnlyList diagnostics) + { + foreach (WotDiagnostic diagnostic in diagnostics) + { + if (diagnostic.Severity == WotDiagnosticSeverity.Error) + { + return diagnostic.ToString(); + } + } + return null; + } + + private static WoTResourceLoadResultDataType FailResult( + WotResource resource, uint generation, WoTPhaseEnum phase, string? message) + => new WoTResourceLoadResultDataType + { + Xid = resource.Xid, + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + Kind = resource.Kind, + Outcome = WoTOutcomeEnum.Failed, + Phase = phase, + LoadState = WoTLoadStateEnum.Failed, + Generation = generation, + MaterializedNodeCount = 0, + ContentDigest = DigestOf(resource), + Message = message ?? string.Empty + }; + + private static WoTResourceLoadResultDataType UnchangedResult( + WotResource resource, uint generation) + => new WoTResourceLoadResultDataType + { + Xid = resource.Xid, + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + VersionId = resource.ActiveVersionId ?? resource.DefaultVersionId ?? string.Empty, + Kind = resource.Kind, + Outcome = WoTOutcomeEnum.Unchanged, + Phase = WoTPhaseEnum.Activation, + LoadState = WoTLoadStateEnum.Active, + Generation = generation, + MaterializedNodeCount = (uint)resource.MaterializedNodeCount, + ContentDigest = DigestOf(resource), + Message = "Content digest unchanged." + }; + + private static WotResourceProjection FailProjection( + WotResource resource, string? message, WoTValidationOutcomeDataType? validation = null) + => new WotResourceProjection( + resource.GroupId, + resource.ResourceId, + WoTLoadStateEnum.Failed, + activeVersionId: null, + resource.RefreshGeneration, + resource.MaterializedNodeCount, + rootNodeId: null, + validation, + string.IsNullOrEmpty(message) + ? ImmutableArray.Empty + : ImmutableArray.Create(message!), + DateTime.UtcNow) + { + // Keep the previous active projection when a refresh fails. + RetainPreviousActiveVersion = true + }; + + private static WoTValidationOutcomeDataType SuccessValidation() + => new WoTValidationOutcomeDataType + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Success, + CompatibilityValidated = true, + CompatibilityOutcome = WoTOutcomeEnum.Success, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + + private static WoTValidationOutcomeDataType FormatFailure(string? reason) + => new WoTValidationOutcomeDataType + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Failed, + FormatReason = reason ?? string.Empty, + CompatibilityValidated = false, + CompatibilityOutcome = WoTOutcomeEnum.Skipped, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + + private void RaiseResource( + WotResource resource, uint generation, WoTOutcomeEnum outcome, WoTLoadStateEnum state) + => RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.Resource) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.Activation, + Outcome = outcome, + LoadState = state + }); + + private void RaiseLoadFailure(WotResource resource, uint generation, string? reason) + => RaiseEvent(new WotMaterializationEventArgs(WotMaterializationEventKind.LoadFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.Projection, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Reason = reason ?? string.Empty + }); + + private void RaiseValidationFailure( + WotResource resource, uint generation, + WoTValidationOutcomeDataType validation, string? reason) + => RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.ValidationFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + VersionId = resource.DefaultVersionId ?? string.Empty, + DocumentKind = resource.Kind, + Generation = generation, + Phase = WoTPhaseEnum.FormatValidation, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Validation = validation, + Reason = reason ?? string.Empty + }); + + private void RaiseBindingFailure(WotResource resource, string? reason) + => RaiseEvent(new WotMaterializationEventArgs( + WotMaterializationEventKind.BindingFailure) + { + Xid = resource.Xid, + ResourceId = resource.ResourceId, + DocumentKind = resource.Kind, + Outcome = WoTOutcomeEnum.Failed, + LoadState = WoTLoadStateEnum.Failed, + Reason = reason ?? string.Empty + }); + + private void RaiseEvent(WotMaterializationEventArgs args) + => Event?.Invoke(this, args); + + private WotRefreshResult RejectedResult( + WotRefreshRequest request, WotRegistrySnapshot snapshot, DateTime start) + { + var summary = new WoTRefreshSummaryDataType + { + RequestId = request.RequestId ?? string.Empty, + Generation = 0, + Outcome = WoTOutcomeEnum.Rejected, + StartTime = start, + EndTime = DateTime.UtcNow + }; + return new WotRefreshResult( + summary, ImmutableArray.Empty, + (uint)snapshot.Generation); + } + + private sealed class ClosureState + { + public string Key { get; set; } = string.Empty; + public WotProjectionHandle? Handle { get; set; } + public byte[] AggregateDigest { get; set; } = Array.Empty(); + public uint Generation { get; set; } + public ImmutableArray MemberXids { get; set; } = ImmutableArray.Empty; + public ImmutableArray BindingPlans { get; set; } + = ImmutableArray.Empty; + } + + private sealed class ClosureOutcome + { + public ClosureOutcome( + ImmutableArray results, + List projections) + { + Results = results; + Projections = projections; + } + + public ImmutableArray Results { get; } + public List Projections { get; } + } + + private readonly IWotRegistryService m_registry; + private readonly IWotProjectionHost m_host; + private readonly IWotBinderRegistry m_binders; + private readonly IWotDocumentConverter m_converter; + private readonly WotNodeSetConverterOptions m_converterOptions; + private readonly SemaphoreSlim m_mutex = new(1, 1); + private readonly Dictionary m_closures = + new(StringComparer.Ordinal); + private uint m_generation; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs new file mode 100644 index 0000000000..d52ea93272 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotMaterializationTypes.cs @@ -0,0 +1,190 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// A request to refresh (re-project) the registry into the AddressSpace. + /// Mirrors the generated WoTRegistryType.Refresh Method signature. + /// + public sealed class WotRefreshRequest + { + /// Gets or sets the resource selectors; empty selects all resources. + public ImmutableArray Selection { get; set; } + = ImmutableArray.Empty; + + /// Gets or sets the refresh options. + public WoTRefreshOptionsDataType Options { get; set; } = new WoTRefreshOptionsDataType(); + + /// + /// Gets or sets the caller's expected registry generation. When non-zero + /// and it does not match the current generation, the refresh is rejected. + /// + public uint ExpectedGeneration { get; set; } + + /// Gets or sets an opaque request id echoed in the summary. + public string RequestId { get; set; } = string.Empty; + } + + /// + /// The detailed result of a refresh, matching the generated + /// WoTRegistryType.Refresh output arguments. + /// + public sealed class WotRefreshResult + { + internal WotRefreshResult( + WoTRefreshSummaryDataType summary, + ImmutableArray results, + uint newGeneration) + { + Summary = summary; + Results = results; + NewGeneration = newGeneration; + } + + /// Gets the overall refresh summary. + public WoTRefreshSummaryDataType Summary { get; } + + /// Gets the per-resource results. + public ImmutableArray Results { get; } + + /// Gets the committed refresh generation. + public uint NewGeneration { get; } + } + + /// The kind of materialization event emitted by the coordinator. + public enum WotMaterializationEventKind + { + /// A refresh completed. + RefreshCompleted, + + /// A resource projection changed state. + Resource, + + /// A resource failed format/compatibility validation. + ValidationFailure, + + /// A resource failed to load/project. + LoadFailure, + + /// A binding failed. + BindingFailure + } + + /// + /// The payload the coordinator raises for each material event. The NodeManager + /// maps it to the generated WoTResourceEventType / + /// WoTValidationFailureEventType / WoTLoadFailureEventType / + /// WoTBindingFailureEventType / WoTRefreshCompletedEventType. + /// + public sealed class WotMaterializationEventArgs : EventArgs + { + internal WotMaterializationEventArgs(WotMaterializationEventKind kind) + { + Kind = kind; + } + + /// Gets the event kind. + public WotMaterializationEventKind Kind { get; } + + /// Gets or sets the affected resource xid. + public string Xid { get; init; } = string.Empty; + + /// Gets or sets the resource id. + public string ResourceId { get; init; } = string.Empty; + + /// Gets or sets the version id. + public string VersionId { get; init; } = string.Empty; + + /// Gets or sets the document kind. + public WoTDocumentKindEnum DocumentKind { get; init; } + + /// Gets or sets the refresh generation. + public uint Generation { get; init; } + + /// Gets or sets the phase reached. + public WoTPhaseEnum Phase { get; init; } + + /// Gets or sets the outcome. + public WoTOutcomeEnum Outcome { get; init; } + + /// Gets or sets the resulting load state. + public WoTLoadStateEnum LoadState { get; init; } + + /// Gets or sets the validation outcome, if any. + public WoTValidationOutcomeDataType? Validation { get; init; } + + /// Gets or sets the failing node id, if any. + public NodeId? FailedNodeId { get; init; } + + /// Gets or sets the binding URI, if any. + public string BindingUri { get; init; } = string.Empty; + + /// Gets or sets a human-readable reason/message. + public string Reason { get; init; } = string.Empty; + + /// Gets or sets the refresh summary (RefreshCompleted only). + public WoTRefreshSummaryDataType? Summary { get; init; } + + /// Gets or sets the request id (RefreshCompleted only). + public string RequestId { get; init; } = string.Empty; + } + + /// + /// An that resolves referenced TD/TM + /// documents from a registry snapshot, so a Thing Description synthesized by + /// the converter can pull in the Thing Models it depends on. + /// + internal sealed class SnapshotThingResolver : IWotThingResolver + { + public SnapshotThingResolver(WotRegistrySnapshot snapshot) + { + m_snapshot = snapshot; + } + + public WotResolverResult ResolveThing(string reference, WotResolutionContext context) + { + WotResource? resource = WotDependencyGraph.Resolve(m_snapshot, reference); + WotResourceVersion? version = resource?.DefaultVersion; + if (version is null) + { + return WotResolverResult.NotFound; + } + return WotResolverResult.FromBytes(version.Content); + } + + private readonly WotRegistrySnapshot m_snapshot; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs new file mode 100644 index 0000000000..7aa539cb03 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Materialization/WotRefreshArguments.cs @@ -0,0 +1,299 @@ +/* ======================================================================== + * 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; +using System.Collections.Generic; +using System.Collections.Immutable; + +namespace Opc.Ua.WotCon.Server.Materialization +{ + /// + /// Decodes the four WoTRegistryType.Refresh input arguments - + /// Selection ([]), + /// Options (), + /// ExpectedGeneration () and RequestId + /// () - into a . Structured + /// arguments are accepted in every form a caller may present them: an already + /// decoded encodeable, an wrapping the + /// encodeable, a binary-encoded ExtensionObject body, and both plain-array and + /// array containers. A value whose type does not match + /// the argument's schema is rejected with + /// rather than silently ignored. + /// + internal static class WotRefreshArguments + { + /// + /// Decodes the Refresh input arguments into a . + /// + /// The raw input argument variants. + /// The message context used to decode encoded bodies. + /// The decoded request on success. + /// + /// on success, or + /// when an argument is present + /// but has the wrong type. + /// + public static ServiceResult TryDecode( + ArrayOf inputArguments, + IServiceMessageContext context, + out WotRefreshRequest request) + { + request = new WotRefreshRequest(); + + ServiceResult selection = TryDecodeSelection( + ArgumentAt(inputArguments, 0), context, + out ImmutableArray selectors); + if (ServiceResult.IsBad(selection)) + { + return selection; + } + + ServiceResult options = TryDecodeStructure( + ArgumentAt(inputArguments, 1), context, + out WoTRefreshOptionsDataType? decodedOptions); + if (ServiceResult.IsBad(options)) + { + return options; + } + + ServiceResult generation = TryDecodeUInt32( + ArgumentAt(inputArguments, 2), out uint expectedGeneration); + if (ServiceResult.IsBad(generation)) + { + return generation; + } + + ServiceResult requestId = TryDecodeString( + ArgumentAt(inputArguments, 3), out string? id); + if (ServiceResult.IsBad(requestId)) + { + return requestId; + } + + request = new WotRefreshRequest + { + Selection = selectors, + Options = decodedOptions ?? new WoTRefreshOptionsDataType(), + ExpectedGeneration = expectedGeneration, + RequestId = id ?? string.Empty + }; + return ServiceResult.Good; + } + + private static Variant ArgumentAt(ArrayOf inputArguments, int index) + => index < inputArguments.Count ? inputArguments[index] : Variant.Null; + + private static ServiceResult TryDecodeSelection( + Variant value, + IServiceMessageContext context, + out ImmutableArray selectors) + { + selectors = ImmutableArray.Empty; + if (value.IsNull) + { + return ServiceResult.Good; + } + + var builder = ImmutableArray.CreateBuilder(); + foreach (object? element in Enumerate(value.AsBoxedObject(Variant.BoxingBehavior.Legacy))) + { + if (element is null) + { + continue; + } + ServiceResult status = TryCoerce( + element, context, out WoTResourceSelectorDataType? selector); + if (ServiceResult.IsBad(status) || selector is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Selection argument must be an array of WoTResourceSelectorDataType."); + } + builder.Add(selector); + } + selectors = builder.ToImmutable(); + return ServiceResult.Good; + } + + private static ServiceResult TryDecodeStructure( + Variant value, + IServiceMessageContext context, + out WoTRefreshOptionsDataType? options) + { + options = null; + if (value.IsNull) + { + return ServiceResult.Good; + } + ServiceResult status = TryCoerce( + value.AsBoxedObject(Variant.BoxingBehavior.Legacy), context, out options); + if (ServiceResult.IsBad(status) || options is null) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The Options argument must be a single WoTRefreshOptionsDataType."); + } + return ServiceResult.Good; + } + + private static ServiceResult TryDecodeUInt32(Variant value, out uint result) + { + result = 0; + if (value.IsNull) + { + return ServiceResult.Good; + } + switch (value.AsBoxedObject(Variant.BoxingBehavior.Legacy)) + { + case uint u: + result = u; + return ServiceResult.Good; + case int i when i >= 0: + result = (uint)i; + return ServiceResult.Good; + case long l when l >= 0 && l <= uint.MaxValue: + result = (uint)l; + return ServiceResult.Good; + case ushort us: + result = us; + return ServiceResult.Good; + case byte b: + result = b; + return ServiceResult.Good; + default: + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The ExpectedGeneration argument must be a UInt32."); + } + } + + private static ServiceResult TryDecodeString(Variant value, out string? result) + { + result = null; + if (value.IsNull) + { + return ServiceResult.Good; + } + if (value.AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s) + { + result = s; + return ServiceResult.Good; + } + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The RequestId argument must be a String."); + } + + private static IEnumerable Enumerate(object? boxed) + { + switch (boxed) + { + case null: + yield break; + case ExtensionObject single: + yield return single; + break; + case IConvertableToArray convertible: + Array? array = convertible.ToArray(); + if (array is not null) + { + foreach (object? item in array) + { + yield return item; + } + } + break; + case IEnumerable enumerable when boxed is not string: + foreach (object? item in enumerable) + { + yield return item; + } + break; + default: + yield return boxed; + break; + } + } + + private static ServiceResult TryCoerce( + object? element, + IServiceMessageContext context, + out T? value) + where T : class, IEncodeable, new() + { + value = null; + switch (element) + { + case T typed: + value = typed; + return ServiceResult.Good; + case ExtensionObject extension: + return TryDecodeExtensionObject(extension, context, out value); + default: + return StatusCodes.BadInvalidArgument; + } + } + + private static ServiceResult TryDecodeExtensionObject( + ExtensionObject extension, + IServiceMessageContext context, + out T? value) + where T : class, IEncodeable, new() + { + value = null; + if (extension.IsNull) + { + return StatusCodes.BadInvalidArgument; + } + if (extension.TryGetValue(out T? typed)) + { + value = typed; + return ServiceResult.Good; + } + if (extension.TryGetAsBinary(out ByteString binary) && !binary.IsNull) + { + try + { + using var decoder = new BinaryDecoder(binary.ToArray(), context); + value = decoder.ReadEncodeable(null); + return ServiceResult.Good; + } + catch (Exception ex) when ( + ex is ServiceResultException or FormatException or InvalidOperationException) + { + return ServiceResult.Create( + ex, StatusCodes.BadInvalidArgument, + "The encoded argument body could not be decoded."); + } + } + return StatusCodes.BadInvalidArgument; + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj b/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj index ddc4d2f039..a5e94277d6 100644 --- a/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj +++ b/src/Opc.Ua.WotCon.Server/Opc.Ua.WotCon.Server.csproj @@ -7,7 +7,7 @@ Opc.Ua.WotCon.Server $(NoWarn);CS1591 enable - OPC UA WoT Connectivity (OPC 10100-1) server class library + OPC UA WoT Connectivity 1.1 server class library — hosts the deprecated OPC 10100-1 v1.02 asset-management surface and the registry-first materialization runtime. true NugetREADME.md true @@ -23,8 +23,11 @@ the binder generator emits 'new Variant((object)value)' whose ctor has RequiresUnreferencedCode/RequiresDynamicCode. The InitialValue path is runtime-only and not bound from appsettings; suppress at project level. + CA1850: the one-shot SHA256.HashData static helper is only available on + .NET 5+, but this library also targets net48/netstandard2.x where the + instance ComputeHash API is the portable equivalent. --> - $(NoWarn);SYSLIB1100;SYSLIB1101;IL2026;IL3050 + $(NoWarn);SYSLIB1100;SYSLIB1101;IL2026;IL3050;CA1850 @@ -36,6 +39,7 @@ + diff --git a/src/Opc.Ua.WotCon.Server/Registry/FileWotRegistryStore.cs b/src/Opc.Ua.WotCon.Server/Registry/FileWotRegistryStore.cs new file mode 100644 index 0000000000..547456ff21 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/FileWotRegistryStore.cs @@ -0,0 +1,679 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Text.Json.Serialization.Metadata; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// A durable, file-backed registry store that persists each committed + /// generation transactionally. Version document bytes are written once into a + /// content-addressed blobs directory (deduplicated by SHA-256 digest); + /// all registry metadata (groups, resources, versions, load/validation state, + /// labels) is captured in a single manifest.json. + /// + /// + /// + /// A first stages every referenced blob durably + /// (write-through to disk, then atomic move into place) and only then switches + /// the committed generation by atomically replacing manifest.json + /// (write-to-temp, write-through, then ). + /// Because the manifest is the single pointer to a generation and it is the + /// last thing written, a crash (or an injected failure) never exposes a + /// half-written generation: the reader either sees the previous manifest in + /// full or the new one in full. reads only the + /// committed manifest and its referenced blobs; staged temp files are ignored. + /// + /// + /// Invalid documents are committed with their failure state so a restart + /// restores exactly the last committed registry contents. Blobs that the newly + /// committed manifest no longer references are pruned on a best-effort basis + /// after the manifest switch. + /// + /// + public sealed class FileWotRegistryStore : IWotRegistryStore + { + /// + /// Initializes a new file-backed store rooted at . + /// + public FileWotRegistryStore(string rootFolder) + { + m_root = rootFolder ?? throw new ArgumentNullException(nameof(rootFolder)); + m_blobsFolder = Path.Combine(m_root, "blobs"); + } + + /// + public async ValueTask LoadAsync( + CancellationToken cancellationToken = default) + { + string manifestPath = Path.Combine(m_root, ManifestFile); + if (!File.Exists(manifestPath)) + { + return WotRegistrySnapshot.Empty; + } + + ManifestDto? manifest = await ReadJsonAsync( + manifestPath, WotRegistryStoreJson.Default.ManifestDto, cancellationToken) + .ConfigureAwait(false); + if (manifest is null) + { + return WotRegistrySnapshot.Empty; + } + + ImmutableSortedDictionary registryLabels = ToLabels(manifest.RegistryLabels); + long generation = manifest.Generation; + + var groups = ImmutableDictionary.CreateBuilder(); + if (manifest.Groups is not null) + { + foreach (GroupDto groupDto in manifest.Groups) + { + cancellationToken.ThrowIfCancellationRequested(); + var resources = ImmutableDictionary.CreateBuilder(); + if (groupDto.Resources is not null) + { + foreach (ResourceDto resourceDto in groupDto.Resources) + { + cancellationToken.ThrowIfCancellationRequested(); + WotResource? resource = await LoadResourceAsync( + resourceDto, cancellationToken).ConfigureAwait(false); + if (resource is not null) + { + resources[resource.ResourceId] = resource; + generation = Math.Max(generation, resource.Epoch); + } + } + } + + var group = new WotResourceGroup( + groupDto.GroupId, + (WoTDocumentKindEnum)groupDto.Kind, + resources.ToImmutable(), + groupDto.Name, + groupDto.Description, + groupDto.Epoch, + ToLabels(groupDto.Labels)); + groups[group.GroupId] = group; + generation = Math.Max(generation, groupDto.Epoch); + } + } + + return new WotRegistrySnapshot(generation, groups.ToImmutable(), registryLabels); + } + + /// + public async ValueTask CommitAsync( + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken = default) + { + snapshot ??= WotRegistrySnapshot.Empty; + + Directory.CreateDirectory(m_root); + Directory.CreateDirectory(m_blobsFolder); + + // 1. Stage every referenced version blob durably before the manifest + // that points at it is switched in. Blobs are content-addressed, so an + // unchanged document is written at most once and shared across + // versions/resources. + var referenced = new HashSet(StringComparer.Ordinal); + foreach (WotResourceGroup group in snapshot.Groups.Values) + { + foreach (WotResource resource in group.Resources.Values) + { + foreach (WotResourceVersion version in resource.Versions) + { + string digestHex = WotContentDigest.ToHex(version.Digest); + if (digestHex.Length == 0) + { + continue; + } + if (referenced.Add(digestHex)) + { + string blobPath = BlobPath(digestHex); + if (!File.Exists(blobPath)) + { + await DurableWriteAsync( + blobPath, version.Content.ToArray(), cancellationToken) + .ConfigureAwait(false); + } + } + } + } + } + + // 2. Build and durably write the manifest, then switch it in with a + // single atomic replace. This is the commit point: until it completes, + // the previously committed generation stays fully intact. + ManifestDto manifest = ToManifest(snapshot); + byte[] manifestBytes = JsonSerializer.SerializeToUtf8Bytes( + manifest, WotRegistryStoreJson.Default.ManifestDto); + await AtomicReplaceAsync( + Path.Combine(m_root, ManifestFile), manifestBytes, cancellationToken) + .ConfigureAwait(false); + + // 3. Best-effort prune of blobs the new committed generation no longer + // references. A failure here never affects correctness of the commit. + PruneOrphanBlobs(referenced); + } + + private async ValueTask LoadResourceAsync( + ResourceDto dto, + CancellationToken cancellationToken) + { + var versions = ImmutableArray.CreateBuilder(); + if (dto.Versions is not null) + { + foreach (VersionDto v in dto.Versions) + { + if (string.IsNullOrEmpty(v.DigestHex)) + { + continue; + } + string blobPath = BlobPath(v.DigestHex!); + if (!File.Exists(blobPath)) + { + continue; + } + byte[] content = await ReadAllBytesAsync(blobPath, cancellationToken) + .ConfigureAwait(false); + versions.Add(new WotResourceVersion( + v.VersionId, + content, + v.ContentType ?? string.Empty, + v.Format ?? string.Empty, + ParseDate(v.CreatedAt), + ParseDate(v.ModifiedAt))); + } + } + + return new WotResource( + dto.GroupId, + dto.ResourceId, + (WoTDocumentKindEnum)dto.Kind, + versions.ToImmutable(), + defaultVersionId: dto.DefaultVersionId, + desiredVersionId: dto.DesiredVersionId, + activeVersionId: dto.ActiveVersionId, + enabled: dto.Enabled, + loadState: (WoTLoadStateEnum)dto.LoadState, + validation: FromDto(dto.Validation), + diagnostics: dto.Diagnostics is null + ? ImmutableArray.Empty + : ImmutableArray.Create(dto.Diagnostics), + epoch: dto.Epoch, + refreshGeneration: dto.RefreshGeneration, + lastRefreshTime: ParseDate(dto.LastRefreshTime), + materializedNodeCount: dto.MaterializedNodeCount, + rootNodeId: ParseNodeId(dto.RootNodeId), + name: dto.Name, + description: dto.Description, + thingId: dto.ThingId, + title: dto.Title, + labels: ToLabels(dto.Labels)); + } + + private static ManifestDto ToManifest(WotRegistrySnapshot snapshot) + { + var groups = new List(snapshot.Groups.Count); + foreach (WotResourceGroup group in snapshot.Groups.Values) + { + var resources = new List(group.Resources.Count); + foreach (WotResource resource in group.Resources.Values) + { + resources.Add(ToDto(resource)); + } + groups.Add(new GroupDto + { + GroupId = group.GroupId, + Kind = (int)group.Kind, + Name = group.Name, + Description = group.Description, + Epoch = group.Epoch, + Labels = FromLabels(group.Labels), + Resources = resources.Count == 0 ? null : resources.ToArray() + }); + } + return new ManifestDto + { + SchemaVersion = CurrentSchemaVersion, + Generation = snapshot.Generation, + RegistryLabels = FromLabels(snapshot.Labels), + Groups = groups.Count == 0 ? null : groups.ToArray() + }; + } + + private static ResourceDto ToDto(WotResource resource) + { + var versions = new VersionDto[resource.Versions.Length]; + for (int i = 0; i < resource.Versions.Length; i++) + { + WotResourceVersion v = resource.Versions[i]; + versions[i] = new VersionDto + { + VersionId = v.VersionId, + ContentType = v.ContentType, + Format = v.Format, + CreatedAt = FormatDate(v.CreatedAt), + ModifiedAt = FormatDate(v.ModifiedAt), + DigestHex = v.DigestHex + }; + } + return new ResourceDto + { + GroupId = resource.GroupId, + ResourceId = resource.ResourceId, + Kind = (int)resource.Kind, + Name = resource.Name, + Description = resource.Description, + DefaultVersionId = resource.DefaultVersionId, + DesiredVersionId = resource.DesiredVersionId, + ActiveVersionId = resource.ActiveVersionId, + Enabled = resource.Enabled, + LoadState = (int)resource.LoadState, + Epoch = resource.Epoch, + RefreshGeneration = resource.RefreshGeneration, + LastRefreshTime = FormatDate(resource.LastRefreshTime), + MaterializedNodeCount = resource.MaterializedNodeCount, + RootNodeId = resource.RootNodeId?.ToString(), + ThingId = resource.ThingId, + Title = resource.Title, + Diagnostics = resource.Diagnostics.IsDefaultOrEmpty + ? null + : System.Linq.Enumerable.ToArray(resource.Diagnostics), + Validation = ToDto(resource.Validation), + Versions = versions.Length == 0 ? null : versions, + Labels = FromLabels(resource.Labels) + }; + } + + /// + /// Converts a possibly-null DTO dictionary into the ordinally-ordered + /// immutable label dictionary, defaulting to . + /// + private static ImmutableSortedDictionary ToLabels( + Dictionary? labels) + { + if (labels is null || labels.Count == 0) + { + return WotLabels.Empty; + } + return ImmutableSortedDictionary.CreateRange(StringComparer.Ordinal, labels); + } + + /// + /// Converts the immutable label dictionary into a plain + /// for JSON serialization, or + /// null when empty (kept out of the persisted document). + /// + private static Dictionary? FromLabels( + ImmutableSortedDictionary labels) + { + return labels.Count == 0 ? null : new Dictionary(labels); + } + + private static ValidationDto? ToDto(WoTValidationOutcomeDataType? validation) + { + if (validation is null) + { + return null; + } + return new ValidationDto + { + FormatValidated = validation.FormatValidated, + FormatOutcome = (int)validation.FormatOutcome, + FormatReason = validation.FormatReason, + CompatibilityValidated = validation.CompatibilityValidated, + CompatibilityOutcome = (int)validation.CompatibilityOutcome, + CompatibilityReason = validation.CompatibilityReason, + CompatibilityPolicy = validation.CompatibilityPolicy, + ValidatedAt = FormatDate(validation.ValidatedAt.ToDateTime()), + VocabularyVersion = validation.VocabularyVersion + }; + } + + private static WoTValidationOutcomeDataType? FromDto(ValidationDto? dto) + { + if (dto is null) + { + return null; + } + return new WoTValidationOutcomeDataType + { + FormatValidated = dto.FormatValidated, + FormatOutcome = (WoTOutcomeEnum)dto.FormatOutcome, + FormatReason = dto.FormatReason, + CompatibilityValidated = dto.CompatibilityValidated, + CompatibilityOutcome = (WoTOutcomeEnum)dto.CompatibilityOutcome, + CompatibilityReason = dto.CompatibilityReason, + CompatibilityPolicy = dto.CompatibilityPolicy, + ValidatedAt = ParseDate(dto.ValidatedAt), + VocabularyVersion = dto.VocabularyVersion + }; + } + + private string BlobPath(string digestHex) + => Path.Combine(m_blobsFolder, digestHex + ".bin"); + + private void PruneOrphanBlobs(HashSet referenced) + { + try + { + if (!Directory.Exists(m_blobsFolder)) + { + return; + } + foreach (string existing in Directory.EnumerateFiles(m_blobsFolder, "*.bin")) + { + string name = Path.GetFileNameWithoutExtension(existing); + if (!referenced.Contains(name)) + { + TryDelete(existing); + } + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static async ValueTask ReadJsonAsync( + string path, + JsonTypeInfo typeInfo, + CancellationToken cancellationToken) + where T : class + { + try + { + byte[] bytes = await ReadAllBytesAsync(path, cancellationToken) + .ConfigureAwait(false); + return JsonSerializer.Deserialize(bytes, typeInfo); + } + catch (Exception ex) when (ex is JsonException or IOException) + { + return null; + } + } + + /// + /// Writes to a fresh content-addressed blob: + /// write-through to a temp file, then move it into place. The temp file is + /// flushed to disk so the blob is durable before the manifest that + /// references it is committed. + /// + private static async ValueTask DurableWriteAsync( + string path, + byte[] bytes, + CancellationToken cancellationToken) + { + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string tmp = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await WriteThroughAsync(tmp, bytes, cancellationToken).ConfigureAwait(false); + if (File.Exists(path)) + { + // A blob with this digest already exists; the content is + // identical, so keep the existing durable copy. + TryDelete(tmp); + return; + } + File.Move(tmp, path); + tmp = null!; + } + finally + { + if (tmp is not null) + { + TryDelete(tmp); + } + } + } + + /// + /// Atomically replaces with : + /// write-through to a temp file, then + /// (or an initial move). This is the commit point for the manifest pointer. + /// + private static async ValueTask AtomicReplaceAsync( + string path, + byte[] bytes, + CancellationToken cancellationToken) + { + string directory = Path.GetDirectoryName(path)!; + Directory.CreateDirectory(directory); + string tmp = path + ".tmp-" + Guid.NewGuid().ToString("N"); + try + { + await WriteThroughAsync(tmp, bytes, cancellationToken).ConfigureAwait(false); + if (File.Exists(path)) + { + // Atomic replace-in-place on the same volume. + File.Replace(tmp, path, null); + } + else + { + File.Move(tmp, path); + } + } + finally + { + TryDelete(tmp); + } + } + + private static async ValueTask WriteThroughAsync( + string path, + byte[] bytes, + CancellationToken cancellationToken) + { + // FileOptions.WriteThrough bypasses the OS write cache so the bytes + // reach stable storage before the handle closes; this preserves the + // "blobs durable before manifest switch" ordering the commit relies on. + using var stream = new FileStream( + path, + FileMode.CreateNew, + FileAccess.Write, + FileShare.None, + bufferSize: 4096, + FileOptions.WriteThrough | FileOptions.Asynchronous); +#if NETSTANDARD2_1_OR_GREATER || NET + await stream.WriteAsync(bytes.AsMemory(), cancellationToken).ConfigureAwait(false); +#else + await stream.WriteAsync(bytes, 0, bytes.Length, cancellationToken).ConfigureAwait(false); +#endif + await stream.FlushAsync(cancellationToken).ConfigureAwait(false); + } + + private static async ValueTask ReadAllBytesAsync( + string path, + CancellationToken cancellationToken) + { +#if NETSTANDARD2_1_OR_GREATER || NET + return await File.ReadAllBytesAsync(path, cancellationToken).ConfigureAwait(false); +#else + await Task.CompletedTask.ConfigureAwait(false); + return File.ReadAllBytes(path); +#endif + } + + private static void TryDelete(string path) + { + try + { + if (File.Exists(path)) + { + File.Delete(path); + } + } + catch (IOException) + { + } + catch (UnauthorizedAccessException) + { + } + } + + private static string FormatDate(DateTime value) + => value.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture); + + private static DateTime ParseDate(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return DateTime.MinValue; + } + return DateTime.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out DateTime parsed) + ? parsed + : DateTime.MinValue; + } + + private static NodeId? ParseNodeId(string? value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + try + { + return NodeId.Parse(value); + } + catch (ServiceResultException) + { + return null; + } + } + + private const string ManifestFile = "manifest.json"; + private const int CurrentSchemaVersion = 2; + + private readonly string m_root; + private readonly string m_blobsFolder; + + internal sealed class ManifestDto + { + public int SchemaVersion { get; set; } + public long Generation { get; set; } + public Dictionary? RegistryLabels { get; set; } + public GroupDto[]? Groups { get; set; } + } + + internal sealed class GroupDto + { + public string GroupId { get; set; } = string.Empty; + public int Kind { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public long Epoch { get; set; } + public Dictionary? Labels { get; set; } + public ResourceDto[]? Resources { get; set; } + } + + internal sealed class VersionDto + { + public string VersionId { get; set; } = string.Empty; + public string? ContentType { get; set; } + public string? Format { get; set; } + public string? CreatedAt { get; set; } + public string? ModifiedAt { get; set; } + public string? DigestHex { get; set; } + } + + internal sealed class ValidationDto + { + public bool FormatValidated { get; set; } + public int FormatOutcome { get; set; } + public string? FormatReason { get; set; } + public bool CompatibilityValidated { get; set; } + public int CompatibilityOutcome { get; set; } + public string? CompatibilityReason { get; set; } + public string? CompatibilityPolicy { get; set; } + public string? ValidatedAt { get; set; } + public string? VocabularyVersion { get; set; } + } + + internal sealed class ResourceDto + { + public string GroupId { get; set; } = string.Empty; + public string ResourceId { get; set; } = string.Empty; + public int Kind { get; set; } + public string Name { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? DefaultVersionId { get; set; } + public string? DesiredVersionId { get; set; } + public string? ActiveVersionId { get; set; } + public bool Enabled { get; set; } + public int LoadState { get; set; } + public long Epoch { get; set; } + public uint RefreshGeneration { get; set; } + public string? LastRefreshTime { get; set; } + public int MaterializedNodeCount { get; set; } + public string? RootNodeId { get; set; } + public string? ThingId { get; set; } + public string? Title { get; set; } + public string[]? Diagnostics { get; set; } + public ValidationDto? Validation { get; set; } + public VersionDto[]? Versions { get; set; } + public Dictionary? Labels { get; set; } + } + } + + /// + /// Source-generated JSON metadata serialization for the file-backed store, + /// keeping the store trimming/AOT-safe (no reflection-based serialization). + /// + [JsonSourceGenerationOptions( + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] + [JsonSerializable(typeof(FileWotRegistryStore.ManifestDto))] + [JsonSerializable(typeof(FileWotRegistryStore.GroupDto))] + [JsonSerializable(typeof(FileWotRegistryStore.ResourceDto))] + [JsonSerializable(typeof(FileWotRegistryStore.VersionDto))] + [JsonSerializable(typeof(FileWotRegistryStore.ValidationDto))] + internal sealed partial class WotRegistryStoreJson : JsonSerializerContext + { + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/IWotRegistryService.cs b/src/Opc.Ua.WotCon.Server/Registry/IWotRegistryService.cs new file mode 100644 index 0000000000..69d4a2602d --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/IWotRegistryService.cs @@ -0,0 +1,398 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// A request to create or update a document resource (upload a TD/TM + /// version). The registry validates and stores the version and, when + /// is set, points the resource default/desired + /// version at the new version. + /// + public sealed class WotUpsertResourceRequest + { + /// Gets or sets the target group id (defaults per ). + public string? GroupId { get; set; } + + /// Gets or sets the resource id; derived from the document when omitted. + public string? ResourceId { get; set; } + + /// Gets or sets the document kind. + public WoTDocumentKindEnum Kind { get; set; } = WoTDocumentKindEnum.ThingDescription; + + /// Gets or sets the raw document source bytes. + public ReadOnlyMemory Content { get; set; } + + /// Gets or sets the document media type. + public string ContentType { get; set; } = "application/td+json"; + + /// Gets or sets the document format tag. + public string Format { get; set; } = "WoT-TD/1.1"; + + /// Gets or sets an optional resource display name. + public string? Name { get; set; } + + /// Gets or sets an optional resource description. + public string? Description { get; set; } + + /// + /// Gets or sets whether the new version becomes the resource's default + /// and desired version. Defaults to true. + /// + public bool SetAsDefault { get; set; } = true; + } + + /// + /// The result of a registry mutation. + /// + public sealed class WotRegistryMutationResult + { + internal WotRegistryMutationResult( + WoTOutcomeEnum outcome, + WotResource? resource, + long generation, + ImmutableArray diagnostics, + string? message = null) + { + Outcome = outcome; + Resource = resource; + Generation = generation; + Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; + Message = message ?? string.Empty; + } + + /// Gets the outcome of the mutation. + public WoTOutcomeEnum Outcome { get; } + + /// Gets the affected resource snapshot, if any. + public WotResource? Resource { get; } + + /// Gets the registry generation after the mutation. + public long Generation { get; } + + /// Gets diagnostics produced by the mutation. + public ImmutableArray Diagnostics { get; } + + /// Gets a human-readable message. + public string Message { get; } + + /// Gets whether the mutation changed the registry contents. + public bool Changed => Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning; + } + + /// + /// Describes a change to the registry snapshot. Content mutations + /// ( is false) drive re-materialization; + /// projection callbacks ( is true) only + /// refresh browseable projection state and must not re-trigger the + /// materialization coordinator. + /// + public sealed class WotRegistryChangedEventArgs : EventArgs + { + internal WotRegistryChangedEventArgs( + WotRegistrySnapshot previous, + WotRegistrySnapshot current, + IReadOnlyList changedResourceXids, + bool projectionOnly) + { + Previous = previous; + Current = current; + ChangedResourceXids = changedResourceXids; + ProjectionOnly = projectionOnly; + } + + /// Gets the snapshot before the change. + public WotRegistrySnapshot Previous { get; } + + /// Gets the snapshot after the change. + public WotRegistrySnapshot Current { get; } + + /// Gets the xids of the resources that changed. + public IReadOnlyList ChangedResourceXids { get; } + + /// + /// Gets whether the change only recorded projection state (and must not + /// re-trigger materialization). + /// + public bool ProjectionOnly { get; } + } + + /// + /// The projection state recorded back into the registry snapshot by the + /// materialization coordinator after a refresh. + /// + public sealed class WotResourceProjection + { + /// Initializes a new projection record. + public WotResourceProjection( + string groupId, + string resourceId, + WoTLoadStateEnum loadState, + string? activeVersionId, + uint refreshGeneration, + int materializedNodeCount, + NodeId? rootNodeId, + WoTValidationOutcomeDataType? validation, + ImmutableArray diagnostics, + DateTime lastRefreshTime) + { + GroupId = groupId; + ResourceId = resourceId; + LoadState = loadState; + ActiveVersionId = activeVersionId; + RefreshGeneration = refreshGeneration; + MaterializedNodeCount = materializedNodeCount; + RootNodeId = rootNodeId; + Validation = validation; + Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; + LastRefreshTime = lastRefreshTime; + } + + /// Gets the group id. + public string GroupId { get; } + + /// Gets the resource id. + public string ResourceId { get; } + + /// Gets the resulting load state. + public WoTLoadStateEnum LoadState { get; } + + /// Gets the active version id, if any. + public string? ActiveVersionId { get; } + + /// Gets the refresh generation. + public uint RefreshGeneration { get; } + + /// Gets the materialized node count. + public int MaterializedNodeCount { get; } + + /// Gets the root node of the projection, if any. + public NodeId? RootNodeId { get; } + + /// Gets the validation outcome, if any. + public WoTValidationOutcomeDataType? Validation { get; } + + /// Gets the diagnostics. + public ImmutableArray Diagnostics { get; } + + /// Gets the UTC last-refresh time. + public DateTime LastRefreshTime { get; } + + /// Gets whether the projection failed to keep a previous active generation. + public bool RetainPreviousActiveVersion { get; init; } + } + + /// + /// The stable, injectable registry service. It owns the current immutable + /// registry snapshot, serialises mutations, enforces resource bounds, + /// persists through an , and raises change + /// notifications the materialization coordinator and NodeManager react to. + /// + public interface IWotRegistryService + { + /// Gets the current immutable snapshot. + WotRegistrySnapshot Current { get; } + + /// Gets the configured resource bounds. + WotRegistryPersistenceBounds Bounds { get; } + + /// + /// Raised after a content mutation (upsert/delete/set-default/set-enabled) + /// or a projection callback. Consumers filter on + /// . + /// + event EventHandler? Changed; + + /// Loads persisted state from the backing store. + ValueTask InitializeAsync(CancellationToken cancellationToken = default); + + /// Gets, or creates, a group. + ValueTask GetOrCreateGroupAsync( + string groupId, + WoTDocumentKindEnum kind, + string? name = null, + CancellationToken cancellationToken = default); + + /// + /// Creates a group, failing (returning null) when a group with the + /// same id already exists. Use for the + /// idempotent create-or-get form. + /// + ValueTask TryCreateGroupAsync( + string groupId, + WoTDocumentKindEnum kind, + string? name = null, + CancellationToken cancellationToken = default); + + /// Deletes a group and every resource it contains. + ValueTask DeleteGroupAsync( + string groupId, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// + /// Gets, or creates, a content-less placeholder resource. The resource is + /// projected only once a version has been uploaded (through the inherited + /// FileType write path or ). + /// + ValueTask<(WotResource Resource, bool Created)> GetOrCreateResourceAsync( + string groupId, + string resourceId, + WoTDocumentKindEnum kind, + CancellationToken cancellationToken = default); + + /// + /// Creates a content-less placeholder resource, failing (returning + /// null) when the resource already exists. Use + /// for the idempotent form. + /// + ValueTask TryCreateResourceAsync( + string groupId, + string resourceId, + WoTDocumentKindEnum kind, + CancellationToken cancellationToken = default); + + /// + /// Validates the default version of a resource (format, and best-effort + /// compatibility), records the outcome as projection state, and returns it + /// without changing the resource's active projection. + /// + ValueTask ValidateResourceAsync( + string groupId, + string resourceId, + CancellationToken cancellationToken = default); + + /// Creates or updates a document resource (uploads a version). + ValueTask UpsertResourceAsync( + WotUpsertResourceRequest request, + CancellationToken cancellationToken = default); + + /// Deletes a resource and all of its versions. + ValueTask DeleteResourceAsync( + string groupId, + string resourceId, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// Sets the default (and desired) version of a resource. + ValueTask SetDefaultVersionAsync( + string groupId, + string resourceId, + string versionId, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// Enables or disables a resource for projection. + ValueTask SetEnabledAsync( + string groupId, + string resourceId, + bool enabled, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// + /// Adds or updates a registry-level xRegistry label (attribute). The + /// registry has no separate per-entity epoch, so + /// is compared against the current + /// snapshot . Throws a + /// for an invalid/reserved key, + /// an over-long value, or when the entity already holds the + /// configured maximum number of labels. + /// + ValueTask AddRegistryLabelAsync( + string key, + string value, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// Removes a registry-level xRegistry label (attribute). + ValueTask RemoveRegistryLabelAsync( + string key, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// + /// Adds or updates a group-level xRegistry label (attribute). See + /// for the validation and + /// concurrency semantics (here compared against the group's own + /// ). + /// + ValueTask AddGroupLabelAsync( + string groupId, + string key, + string value, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// Removes a group-level xRegistry label (attribute). + ValueTask RemoveGroupLabelAsync( + string groupId, + string key, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// + /// Adds or updates a resource-level xRegistry label (attribute). See + /// for the validation and + /// concurrency semantics (here compared against the resource's own + /// ). + /// + ValueTask AddResourceLabelAsync( + string groupId, + string resourceId, + string key, + string value, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// Removes a resource-level xRegistry label (attribute). + ValueTask RemoveResourceLabelAsync( + string groupId, + string resourceId, + string key, + long? expectedEpoch = null, + CancellationToken cancellationToken = default); + + /// + /// Records projection state produced by the materialization coordinator + /// back into the snapshot so the NodeManager can browse load state, + /// active version, generation and validation outcome. Raises a + /// projection-only change and never re-triggers materialization. + /// + ValueTask ApplyProjectionResultsAsync( + IReadOnlyList projections, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/IWotRegistryStore.cs b/src/Opc.Ua.WotCon.Server/Registry/IWotRegistryStore.cs new file mode 100644 index 0000000000..b9a848a685 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/IWotRegistryStore.cs @@ -0,0 +1,83 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// Persists the immutable registry snapshot behind a transactional + /// commit contract. A store owns exactly one committed generation and + /// exposes it through ; a mutation is made durable by + /// committing a complete replacement generation through + /// . + /// + /// + /// + /// is all-or-nothing. It must make the entire + /// durable and then switch the committed + /// generation in a single atomic step, so a crash (or an injected failure) + /// either leaves the previous generation fully intact or exposes the new + /// generation in full — never a partially written mix. Invalid documents are + /// committed together with their failure state, so a restart restores exactly + /// the last committed registry contents. + /// + /// + /// The relies on this guarantee: it commits + /// before it publishes the new snapshot or raises its change + /// notification. When a commit throws, the caller-visible current snapshot is + /// left unchanged, no notification is raised, and a retry re-attempts the same + /// commit. + /// + /// + public interface IWotRegistryStore + { + /// + /// Loads the last committed registry generation into an immutable + /// snapshot. Returns when no + /// generation has ever been committed. Never observes a partially + /// written (staged, not-yet-committed) generation. + /// + ValueTask LoadAsync(CancellationToken cancellationToken = default); + + /// + /// Durably and atomically commits as the new + /// committed generation, replacing the previous one in full. Stages all + /// backing state first and only switches the committed generation once it + /// is durable, so the operation is all-or-nothing: on failure the store + /// retains its previous committed generation unchanged. + /// + /// The complete registry generation to commit. + /// A cancellation token. + ValueTask CommitAsync( + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken = default); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/InMemoryWotRegistryStore.cs b/src/Opc.Ua.WotCon.Server/Registry/InMemoryWotRegistryStore.cs new file mode 100644 index 0000000000..61ff4527b4 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/InMemoryWotRegistryStore.cs @@ -0,0 +1,67 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// A process-local registry store that keeps its committed generation in + /// memory only. It implements the same transactional commit semantics as the + /// durable — a commit atomically switches + /// the single committed snapshot reference, so a failed commit leaves the + /// previously committed generation intact — but it persists nothing to disk, + /// so a fresh store instance (a process restart) starts from an empty + /// registry. Useful for tests and for servers whose documents are + /// re-populated programmatically at start-up. + /// + public sealed class InMemoryWotRegistryStore : IWotRegistryStore + { + /// + public ValueTask LoadAsync( + CancellationToken cancellationToken = default) + { + return new ValueTask(Volatile.Read(ref m_committed)); + } + + /// + public ValueTask CommitAsync( + WotRegistrySnapshot snapshot, + CancellationToken cancellationToken = default) + { + // The immutable snapshot is committed by a single atomic reference + // switch, so a reader (LoadAsync) never observes a partial generation. + Volatile.Write(ref m_committed, snapshot ?? WotRegistrySnapshot.Empty); + return default; + } + + private WotRegistrySnapshot m_committed = WotRegistrySnapshot.Empty; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/WotLabelValidator.cs b/src/Opc.Ua.WotCon.Server/Registry/WotLabelValidator.cs new file mode 100644 index 0000000000..d327bef157 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/WotLabelValidator.cs @@ -0,0 +1,112 @@ +/* ======================================================================== + * 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 Opc.Ua.WotCon.Server.Assets; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// Validates xRegistry label (attribute) keys and values before they flow + /// into a materialized Labels (AttributesType) container: the key + /// becomes the child BrowseName and a + /// deterministic path segment. Reuses + /// for the shared control/BIDI/path + /// character checks and additionally rejects keys that would collide with + /// the container's own fixed AddAttribute/RemoveAttribute + /// Method BrowseNames. + /// + internal static class WotLabelValidator + { + /// + /// Validates a label key against the configured bounds, reserved + /// AttributesType member names and character-safety rules. + /// + public static ServiceResult ValidateKey(string? key, WotRegistryPersistenceBounds bounds) + { + if (string.IsNullOrEmpty(key)) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The Key argument is required."); + } + if (key!.Length > bounds.MaxLabelKeyLength) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The label key exceeds the maximum length of {0} characters.", + bounds.MaxLabelKeyLength); + } + if (string.Equals(key, Opc.Ua.XRegistry.BrowseNames.AddAttribute, StringComparison.Ordinal) || + string.Equals(key, Opc.Ua.XRegistry.BrowseNames.RemoveAttribute, StringComparison.Ordinal)) + { + return ServiceResult.Create( + StatusCodes.BadBrowseNameDuplicated, + "The label key '{0}' collides with a fixed Labels container member.", + key); + } + return WotChildNameValidator.Validate(key); + } + + /// + /// Validates a label value against the configured maximum length. + /// Any string content is otherwise accepted: the value is a + /// read-only Property value, not a BrowseName/NodeId path segment. + /// + public static ServiceResult ValidateValue(string? value, WotRegistryPersistenceBounds bounds) + { + if (value is not null && value.Length > bounds.MaxLabelValueLength) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, + "The label value exceeds the maximum length of {0} characters.", + bounds.MaxLabelValueLength); + } + return ServiceResult.Good; + } + + /// + /// Validates a key/value pair and throws a + /// with a precise StatusCode on + /// the first failing check. + /// + public static void Validate(string? key, string? value, WotRegistryPersistenceBounds bounds) + { + ServiceResult keyResult = ValidateKey(key, bounds); + if (ServiceResult.IsBad(keyResult)) + { + throw new ServiceResultException(keyResult); + } + ServiceResult valueResult = ValidateValue(value, bounds); + if (ServiceResult.IsBad(valueResult)) + { + throw new ServiceResultException(valueResult); + } + } + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/WotRegistryModel.cs b/src/Opc.Ua.WotCon.Server/Registry/WotRegistryModel.cs new file mode 100644 index 0000000000..88b2f62bb1 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/WotRegistryModel.cs @@ -0,0 +1,555 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.Linq; +using System.Security.Cryptography; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// Computes the content digest used to detect unchanged registry documents. + /// + /// + /// The digest is a SHA-256 over the raw source bytes. It backs the + /// ContentDigest surfaced on the generated WoTDocumentType + /// and the idempotency check that lets an unchanged refresh return the + /// outcome without re-projecting. + /// + public static class WotContentDigest + { + /// + /// Computes the SHA-256 digest of the supplied document bytes. + /// + public static byte[] Compute(ReadOnlyMemory content) + { + using var sha = SHA256.Create(); + if (System.Runtime.InteropServices.MemoryMarshal.TryGetArray( + content, out ArraySegment segment) && + segment.Array is not null) + { + return sha.ComputeHash(segment.Array, segment.Offset, segment.Count); + } + return sha.ComputeHash(content.ToArray()); + } + + /// + /// Formats a digest as a lowercase hexadecimal string, or the empty + /// string when is null or empty. + /// + public static string ToHex(byte[]? digest) + { + if (digest is null || digest.Length == 0) + { + return string.Empty; + } + var chars = new char[digest.Length * 2]; + for (int i = 0; i < digest.Length; i++) + { + byte b = digest[i]; + chars[i * 2] = GetHexChar(b >> 4); + chars[(i * 2) + 1] = GetHexChar(b & 0xF); + } + return new string(chars); + } + + private static char GetHexChar(int nibble) + => (char)(nibble < 10 ? '0' + nibble : 'a' + (nibble - 10)); + + /// + /// Determines whether two digests are byte-for-byte equal. + /// + public static bool Equal(byte[]? left, byte[]? right) + { + if (ReferenceEquals(left, right)) + { + return true; + } + if (left is null || right is null) + { + return false; + } + return left.AsSpan().SequenceEqual(right); + } + } + + /// + /// An immutable snapshot of a single stored document version (an xRegistry + /// Version under a Resource). The raw bytes are the + /// authoritative source and are never mutated. + /// + public sealed class WotResourceVersion + { + /// + /// Initializes a new immutable version snapshot. + /// + public WotResourceVersion( + string versionId, + ReadOnlyMemory content, + string contentType, + string format, + DateTime createdAt, + DateTime modifiedAt, + byte[]? digest = null) + { + VersionId = versionId ?? throw new ArgumentNullException(nameof(versionId)); + Content = content; + ContentType = contentType ?? string.Empty; + Format = format ?? string.Empty; + CreatedAt = createdAt; + ModifiedAt = modifiedAt; + Digest = digest ?? WotContentDigest.Compute(content); + } + + /// Gets the xRegistry versionid. + public string VersionId { get; } + + /// Gets the raw, authoritative document source bytes. + public ReadOnlyMemory Content { get; } + + /// Gets the media type of the document (for example application/td+json). + public string ContentType { get; } + + /// Gets the document format tag (for example WoT-TD/1.1). + public string Format { get; } + + /// Gets the UTC creation time. + public DateTime CreatedAt { get; } + + /// Gets the UTC modification time. + public DateTime ModifiedAt { get; } + + /// Gets the SHA-256 content digest of . + public byte[] Digest { get; } + + /// Gets the content digest as a lowercase hexadecimal string. + public string DigestHex => WotContentDigest.ToHex(Digest); + } + + /// + /// Helpers for the xRegistry label/attribute dictionaries carried by + /// , and + /// . Ordinal key ordering keeps + /// enumeration (and therefore NodeManager materialization order) + /// deterministic regardless of insertion order. + /// + public static class WotLabels + { + /// Gets the empty, ordinally-ordered label dictionary. + public static ImmutableSortedDictionary Empty { get; } = + ImmutableSortedDictionary.Create(StringComparer.Ordinal); + } + + /// + /// An immutable snapshot of a single registered document resource, carrying + /// its versions, desired/active version pointers, and load/validation state. + /// + public sealed class WotResource + { + /// + /// Initializes a new immutable resource snapshot. + /// + public WotResource( + string groupId, + string resourceId, + WoTDocumentKindEnum kind, + ImmutableArray versions, + string? defaultVersionId = null, + string? desiredVersionId = null, + string? activeVersionId = null, + bool enabled = true, + WoTLoadStateEnum loadState = WoTLoadStateEnum.Unloaded, + WoTValidationOutcomeDataType? validation = null, + ImmutableArray diagnostics = default, + long epoch = 0, + uint refreshGeneration = 0, + DateTime lastRefreshTime = default, + int materializedNodeCount = 0, + NodeId? rootNodeId = null, + string? name = null, + string? description = null, + string? thingId = null, + string? title = null, + ImmutableSortedDictionary? labels = null) + { + GroupId = groupId ?? throw new ArgumentNullException(nameof(groupId)); + ResourceId = resourceId ?? throw new ArgumentNullException(nameof(resourceId)); + Kind = kind; + Versions = versions.IsDefault ? ImmutableArray.Empty : versions; + DefaultVersionId = defaultVersionId; + DesiredVersionId = desiredVersionId ?? defaultVersionId; + ActiveVersionId = activeVersionId; + Enabled = enabled; + LoadState = loadState; + Validation = validation; + Diagnostics = diagnostics.IsDefault ? ImmutableArray.Empty : diagnostics; + Epoch = epoch; + RefreshGeneration = refreshGeneration; + LastRefreshTime = lastRefreshTime; + MaterializedNodeCount = materializedNodeCount; + RootNodeId = rootNodeId; + Name = name ?? resourceId; + Description = description ?? string.Empty; + ThingId = thingId; + Title = title; + Labels = labels ?? WotLabels.Empty; + } + + /// Gets the owning group id. + public string GroupId { get; } + + /// Gets the xRegistry resourceid. + public string ResourceId { get; } + + /// Gets the xRegistry xid (/groups/{group}/resources/{resource}). + public string Xid => $"/groups/{GroupId}/resources/{ResourceId}"; + + /// Gets whether the document is a Thing Description or Thing Model. + public WoTDocumentKindEnum Kind { get; } + + /// Gets the immutable set of versions, oldest first. + public ImmutableArray Versions { get; } + + /// Gets the versionid marked as default (the projected version). + public string? DefaultVersionId { get; } + + /// Gets the versionid the operator wants active. + public string? DesiredVersionId { get; } + + /// Gets the versionid currently projected into the AddressSpace. + public string? ActiveVersionId { get; } + + /// Gets whether the resource is enabled for projection. + public bool Enabled { get; } + + /// Gets the current load/projection state. + public WoTLoadStateEnum LoadState { get; } + + /// Gets the last validation outcome, if any. + public WoTValidationOutcomeDataType? Validation { get; } + + /// Gets the human-readable diagnostics for the last operation. + public ImmutableArray Diagnostics { get; } + + /// Gets the resource epoch (bumped on every mutation). + public long Epoch { get; } + + /// Gets the refresh generation of the active projection. + public uint RefreshGeneration { get; } + + /// Gets the UTC time of the last refresh. + public DateTime LastRefreshTime { get; } + + /// Gets the number of AddressSpace nodes materialized for the active projection. + public int MaterializedNodeCount { get; } + + /// Gets the root node of the active projection, if any. + public NodeId? RootNodeId { get; } + + /// Gets the resource display name. + public string Name { get; } + + /// Gets the resource description. + public string Description { get; } + + /// Gets the WoT Thing id parsed from the default document (TD only). + public string? ThingId { get; } + + /// Gets the WoT title parsed from the default document. + public string? Title { get; } + + /// + /// Gets the resource's extensible xRegistry labels/attributes, + /// ordinally ordered by key. Materialized as the resource's + /// browseable Labels (AttributesType) container. + /// + public ImmutableSortedDictionary Labels { get; } + + /// Gets the default (or desired) version snapshot, if present. + public WotResourceVersion? DefaultVersion + => FindVersion(DesiredVersionId ?? DefaultVersionId); + + /// Gets the active version snapshot, if present. + public WotResourceVersion? ActiveVersion => FindVersion(ActiveVersionId); + + /// Finds a version by id. + public WotResourceVersion? FindVersion(string? versionId) + { + if (string.IsNullOrEmpty(versionId)) + { + return null; + } + foreach (WotResourceVersion version in Versions) + { + if (string.Equals(version.VersionId, versionId, StringComparison.Ordinal)) + { + return version; + } + } + return null; + } + + /// + /// Creates a copy of this resource with selected fields replaced. + /// + public WotResource With( + ImmutableArray? versions = null, + string? defaultVersionId = null, + string? desiredVersionId = null, + string? activeVersionId = null, + bool? enabled = null, + WoTLoadStateEnum? loadState = null, + WoTValidationOutcomeDataType? validation = null, + ImmutableArray? diagnostics = null, + long? epoch = null, + uint? refreshGeneration = null, + DateTime? lastRefreshTime = null, + int? materializedNodeCount = null, + NodeId? rootNodeId = null, + string? name = null, + string? description = null, + string? thingId = null, + string? title = null, + ImmutableSortedDictionary? labels = null, + bool clearActiveVersion = false, + bool clearValidation = false, + bool clearRootNodeId = false) + { + return new WotResource( + GroupId, + ResourceId, + Kind, + versions ?? Versions, + defaultVersionId ?? DefaultVersionId, + desiredVersionId ?? DesiredVersionId, + clearActiveVersion ? null : (activeVersionId ?? ActiveVersionId), + enabled ?? Enabled, + loadState ?? LoadState, + clearValidation ? null : (validation ?? Validation), + diagnostics ?? Diagnostics, + epoch ?? Epoch, + refreshGeneration ?? RefreshGeneration, + lastRefreshTime ?? LastRefreshTime, + materializedNodeCount ?? MaterializedNodeCount, + clearRootNodeId ? null : (rootNodeId ?? RootNodeId), + name ?? Name, + description ?? Description, + thingId ?? ThingId, + title ?? Title, + labels ?? Labels); + } + } + + /// + /// An immutable snapshot of a document group (an xRegistry Group). A group + /// is homogeneous in : the well-known + /// ThingDescriptions and ThingModels groups map to + /// and + /// . + /// + public sealed class WotResourceGroup + { + /// + /// Initializes a new immutable group snapshot. + /// + public WotResourceGroup( + string groupId, + WoTDocumentKindEnum kind, + ImmutableDictionary? resources = null, + string? name = null, + string? description = null, + long epoch = 0, + ImmutableSortedDictionary? labels = null) + { + GroupId = groupId ?? throw new ArgumentNullException(nameof(groupId)); + Kind = kind; + Resources = resources ?? ImmutableDictionary.Empty; + Name = name ?? groupId; + Description = description ?? string.Empty; + Epoch = epoch; + Labels = labels ?? WotLabels.Empty; + } + + /// Gets the xRegistry groupid. + public string GroupId { get; } + + /// Gets the xRegistry xid (/groups/{group}). + public string Xid => $"/groups/{GroupId}"; + + /// Gets the document kind shared by all resources in this group. + public WoTDocumentKindEnum Kind { get; } + + /// Gets the resources keyed by resourceid. + public ImmutableDictionary Resources { get; } + + /// Gets the group display name. + public string Name { get; } + + /// Gets the group description. + public string Description { get; } + + /// Gets the group epoch. + public long Epoch { get; } + + /// + /// Gets the group's extensible xRegistry labels/attributes, ordinally + /// ordered by key. Materialized as the group's browseable + /// Labels (AttributesType) container. + /// + public ImmutableSortedDictionary Labels { get; } + + /// Returns a copy of this group with the resource set replaced. + public WotResourceGroup WithResources( + ImmutableDictionary resources, + long epoch) + { + return new WotResourceGroup(GroupId, Kind, resources, Name, Description, epoch, Labels); + } + + /// Returns a copy of this group with the label set replaced. + public WotResourceGroup WithLabels( + ImmutableSortedDictionary labels, + long epoch) + { + return new WotResourceGroup(GroupId, Kind, Resources, Name, Description, epoch, labels); + } + } + + /// + /// An immutable, point-in-time snapshot of the entire WoT registry. Each + /// mutation of the registry produces a new snapshot with a strictly greater + /// ; readers hold a snapshot reference and never see + /// a partially-applied change. + /// + public sealed class WotRegistrySnapshot + { + /// Gets the empty snapshot (generation 0, no groups). + public static WotRegistrySnapshot Empty { get; } = + new WotRegistrySnapshot(0, ImmutableDictionary.Empty); + + /// + /// Initializes a new immutable registry snapshot. + /// + public WotRegistrySnapshot( + long generation, + ImmutableDictionary groups, + ImmutableSortedDictionary? labels = null) + { + Generation = generation; + Groups = groups ?? ImmutableDictionary.Empty; + Labels = labels ?? WotLabels.Empty; + } + + /// + /// Gets the monotonically increasing snapshot generation (registry epoch). + /// + public long Generation { get; } + + /// Gets the groups keyed by groupid. + public ImmutableDictionary Groups { get; } + + /// + /// Gets the registry-level extensible xRegistry labels/attributes, + /// ordinally ordered by key. Materialized as the well-known + /// WoTRegistry object's browseable Labels + /// (AttributesType) container. Optimistic-concurrency checks against + /// these labels compare against , since the + /// singleton registry object has no separate per-entity epoch. + /// + public ImmutableSortedDictionary Labels { get; } + + /// Enumerates every resource across all groups. + public IEnumerable AllResources() + { + foreach (WotResourceGroup group in Groups.Values) + { + foreach (WotResource resource in group.Resources.Values) + { + yield return resource; + } + } + } + + /// Enumerates every resource of the requested kind. + public IEnumerable ResourcesOfKind(WoTDocumentKindEnum kind) + => AllResources().Where(r => r.Kind == kind); + + /// Finds a group by id, or null. + public WotResourceGroup? FindGroup(string groupId) + => Groups.TryGetValue(groupId, out WotResourceGroup? group) ? group : null; + + /// Finds a resource by group and resource id, or null. + public WotResource? FindResource(string groupId, string resourceId) + { + if (Groups.TryGetValue(groupId, out WotResourceGroup? group) && + group.Resources.TryGetValue(resourceId, out WotResource? resource)) + { + return resource; + } + return null; + } + + /// Finds a resource by its xRegistry xid, or null. + public WotResource? FindResourceByXid(string xid) + => AllResources().FirstOrDefault( + r => string.Equals(r.Xid, xid, StringComparison.Ordinal)); + + /// + /// Produces a new snapshot with upserted and the + /// generation advanced to . + /// + public WotRegistrySnapshot WithGroup(WotResourceGroup group, long generation) + { + if (group is null) + { + throw new ArgumentNullException(nameof(group)); + } + return new WotRegistrySnapshot(generation, Groups.SetItem(group.GroupId, group), Labels); + } + + /// Produces a new snapshot with a group removed. + public WotRegistrySnapshot WithoutGroup(string groupId, long generation) + => new WotRegistrySnapshot(generation, Groups.Remove(groupId), Labels); + + /// Produces a new snapshot with the registry-level label set replaced. + public WotRegistrySnapshot WithLabels( + ImmutableSortedDictionary labels, long generation) + => new WotRegistrySnapshot(generation, Groups, labels); + + /// + /// Formats a monotonic version id from the sequence number, using the + /// zero-padded, lexicographically sortable form used by the file store. + /// + public static string FormatVersionId(long sequence) + => sequence.ToString("D19", CultureInfo.InvariantCulture); + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/WotRegistryPersistenceBounds.cs b/src/Opc.Ua.WotCon.Server/Registry/WotRegistryPersistenceBounds.cs new file mode 100644 index 0000000000..dc464f0613 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/WotRegistryPersistenceBounds.cs @@ -0,0 +1,120 @@ +/* ======================================================================== + * 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; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// Bounds enforced by the registry service before a document is accepted or + /// persisted. They cap the resource cost of a hostile or misbehaving client + /// and mirror the bounded-resolution limits used by the + /// . + /// + public sealed class WotRegistryPersistenceBounds + { + /// Gets or sets the maximum accepted size of a single document. + public int MaxDocumentBytes { get; set; } = 4 * 1024 * 1024; + + /// Gets or sets the maximum number of retained versions per resource. + public int MaxVersionsPerResource { get; set; } = 32; + + /// Gets or sets the maximum number of resources per group. + public int MaxResourcesPerGroup { get; set; } = 1024; + + /// Gets or sets the maximum number of groups. + public int MaxGroups { get; set; } = 64; + + /// + /// Gets or sets the maximum number of concurrently open FileType handles + /// per document resource. + /// + public int MaxOpenFileHandles { get; set; } = 8; + + /// + /// Gets or sets the maximum JSON nesting depth accepted when the + /// service parses a document for metadata extraction. + /// + public int MaxJsonDepth { get; set; } = 64; + + /// + /// Gets or sets the maximum number of xRegistry labels/attributes + /// retained on a single entity (registry, group or resource). + /// + public int MaxLabelsPerEntity { get; set; } = 64; + + /// + /// Gets or sets the maximum length of a label key. Also bounds the + /// BrowseName/NodeId path segment materialized for the label. + /// + public int MaxLabelKeyLength { get; set; } = 128; + + /// + /// Gets or sets the maximum length of a label value. + /// + public int MaxLabelValueLength { get; set; } = 4096; + + /// + /// Validates the bounds and throws when any limit is not strictly positive. + /// + public void Validate() + { + EnsurePositive(MaxDocumentBytes, nameof(MaxDocumentBytes)); + EnsurePositive(MaxVersionsPerResource, nameof(MaxVersionsPerResource)); + EnsurePositive(MaxResourcesPerGroup, nameof(MaxResourcesPerGroup)); + EnsurePositive(MaxGroups, nameof(MaxGroups)); + EnsurePositive(MaxJsonDepth, nameof(MaxJsonDepth)); + EnsurePositive(MaxLabelsPerEntity, nameof(MaxLabelsPerEntity)); + EnsurePositive(MaxLabelKeyLength, nameof(MaxLabelKeyLength)); + EnsurePositive(MaxLabelValueLength, nameof(MaxLabelValueLength)); + } + + private static void EnsurePositive(int value, string name) + { + if (value <= 0) + { + throw new ArgumentOutOfRangeException( + name, value, "The configured limit must be a positive value."); + } + } + } + + /// + /// The well-known group identifiers a WoT registry always exposes: the two + /// reserved Thing Description and Thing Model groups. + /// + public static class WotRegistryGroups + { + /// The reserved Thing Description group id. + public const string ThingDescriptions = "thingdescriptions"; + + /// The reserved Thing Model group id. + public const string ThingModels = "thingmodels"; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/WotRegistryService.cs b/src/Opc.Ua.WotCon.Server/Registry/WotRegistryService.cs new file mode 100644 index 0000000000..3973563dbe --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/WotRegistryService.cs @@ -0,0 +1,1120 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Wot; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// The default . Owns the current + /// immutable , serialises every mutation on + /// a single lock, enforces the configured , + /// and persists through the injected . Every + /// mutation is made durable by an atomic + /// before the new snapshot is published to or + /// the notification is raised, so a persistence failure + /// leaves unchanged, raises no event, and a retry + /// re-attempts the same commit. + /// + public sealed class WotRegistryService : IWotRegistryService, IDisposable + { + /// + /// Initializes a new registry service over the supplied store. + /// + public WotRegistryService( + IWotRegistryStore? store = null, + WotRegistryPersistenceBounds? bounds = null) + { + m_store = store ?? new InMemoryWotRegistryStore(); + m_bounds = bounds ?? new WotRegistryPersistenceBounds(); + m_bounds.Validate(); + m_snapshot = WotRegistrySnapshot.Empty; + } + + /// + public WotRegistrySnapshot Current => Volatile.Read(ref m_snapshot); + + /// + public WotRegistryPersistenceBounds Bounds => m_bounds; + + /// + public event EventHandler? Changed; + + /// + public async ValueTask InitializeAsync(CancellationToken cancellationToken = default) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot loaded = await m_store + .LoadAsync(cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, loaded); + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask GetOrCreateGroupAsync( + string groupId, + WoTDocumentKindEnum kind, + string? name = null, + CancellationToken cancellationToken = default) + { + groupId = NormalizeSegment(groupId, nameof(groupId)); + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResourceGroup? existing = snapshot.FindGroup(groupId); + if (existing is not null) + { + return existing; + } + if (snapshot.Groups.Count >= m_bounds.MaxGroups) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"The registry already holds the maximum of {m_bounds.MaxGroups} groups."); + } + long generation = snapshot.Generation + 1; + var group = new WotResourceGroup( + groupId, kind, name: name, epoch: generation); + WotRegistrySnapshot next = snapshot.WithGroup(group, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { group.Xid }, projectionOnly: false); + return group; + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask TryCreateGroupAsync( + string groupId, + WoTDocumentKindEnum kind, + string? name = null, + CancellationToken cancellationToken = default) + { + groupId = NormalizeSegment(groupId, nameof(groupId)); + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + if (snapshot.FindGroup(groupId) is not null) + { + return null; + } + if (snapshot.Groups.Count >= m_bounds.MaxGroups) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"The registry already holds the maximum of {m_bounds.MaxGroups} groups."); + } + long generation = snapshot.Generation + 1; + var group = new WotResourceGroup(groupId, kind, name: name, epoch: generation); + WotRegistrySnapshot next = snapshot.WithGroup(group, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { group.Xid }, projectionOnly: false); + return group; + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask DeleteGroupAsync( + string groupId, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResourceGroup? group = snapshot.FindGroup(groupId); + if (group is null) + { + return Failed(snapshot.Generation, "Group not found."); + } + if (expectedEpoch is { } epoch && epoch != group.Epoch) + { + return Rejected(snapshot.Generation, "Epoch mismatch."); + } + long generation = snapshot.Generation + 1; + WotRegistrySnapshot next = snapshot.WithoutGroup(groupId, generation); + var changed = new List { group.Xid }; + foreach (WotResource resource in group.Resources.Values) + { + changed.Add(resource.Xid); + } + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, changed, projectionOnly: false); + return new WotRegistryMutationResult( + WoTOutcomeEnum.Success, null, generation, ImmutableArray.Empty); + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask<(WotResource Resource, bool Created)> GetOrCreateResourceAsync( + string groupId, + string resourceId, + WoTDocumentKindEnum kind, + CancellationToken cancellationToken = default) + { + groupId = NormalizeSegment(groupId, nameof(groupId)); + resourceId = NormalizeSegment(resourceId, nameof(resourceId)); + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotResource? existing = m_snapshot.FindResource(groupId, resourceId); + if (existing is not null) + { + return (existing, false); + } + WotResource created = await CreatePlaceholderLockedAsync( + groupId, resourceId, kind, cancellationToken).ConfigureAwait(false); + return (created, true); + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask TryCreateResourceAsync( + string groupId, + string resourceId, + WoTDocumentKindEnum kind, + CancellationToken cancellationToken = default) + { + groupId = NormalizeSegment(groupId, nameof(groupId)); + resourceId = NormalizeSegment(resourceId, nameof(resourceId)); + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (m_snapshot.FindResource(groupId, resourceId) is not null) + { + return null; + } + return await CreatePlaceholderLockedAsync( + groupId, resourceId, kind, cancellationToken).ConfigureAwait(false); + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask ValidateResourceAsync( + string groupId, + string resourceId, + CancellationToken cancellationToken = default) + { + WotResource? resource = m_snapshot.FindResource(groupId, resourceId); + if (resource is null) + { + throw new ServiceResultException( + StatusCodes.BadNodeIdUnknown, "Resource not found."); + } + WotResourceVersion? version = resource.DefaultVersion; + if (version is null) + { + throw new ServiceResultException( + StatusCodes.BadInvalidState, "The resource has no default version to validate."); + } + WoTValidationOutcomeDataType outcome = ValidateContent(version.Content); + + await MutateResourceAsync( + groupId, + resourceId, + expectedEpoch: null, + (current, generation) => ( + current.With(validation: outcome, epoch: current.Epoch), + null), + cancellationToken).ConfigureAwait(false); + return outcome; + } + + private async ValueTask CreatePlaceholderLockedAsync( + string groupId, + string resourceId, + WoTDocumentKindEnum kind, + CancellationToken cancellationToken) + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResourceGroup? group = snapshot.FindGroup(groupId); + if (group is null) + { + // Implicit group creation must enforce MaxGroups identically to the + // explicit GetOrCreateGroupAsync / TryCreateGroupAsync paths. + if (snapshot.Groups.Count >= m_bounds.MaxGroups) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"The registry already holds the maximum of {m_bounds.MaxGroups} groups."); + } + group = new WotResourceGroup(groupId, kind, epoch: snapshot.Generation + 1); + } + if (group.Resources.Count >= m_bounds.MaxResourcesPerGroup) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"Group '{groupId}' already holds the maximum of " + + $"{m_bounds.MaxResourcesPerGroup} resources."); + } + long generation = snapshot.Generation + 1; + var resource = new WotResource( + groupId, + resourceId, + kind, + ImmutableArray.Empty, + enabled: true, + loadState: WoTLoadStateEnum.Unloaded, + epoch: generation, + name: resourceId); + WotRegistrySnapshot next = ReplaceResource(snapshot, group, resource, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { resource.Xid }, projectionOnly: false); + return resource; + } + + private static WoTValidationOutcomeDataType ValidateContent(ReadOnlyMemory content) + { + try + { + using WotDocument document = WotDocument.Parse(content); + _ = document.Id; + return new WoTValidationOutcomeDataType + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Success, + CompatibilityValidated = false, + CompatibilityOutcome = WoTOutcomeEnum.Skipped, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + } + catch (Exception ex) when (ex is JsonException or FormatException) + { + return FailedValidation(ex.Message); + } + } + + /// + public async ValueTask UpsertResourceAsync( + WotUpsertResourceRequest request, + CancellationToken cancellationToken = default) + { + if (request is null) + { + throw new ArgumentNullException(nameof(request)); + } + if (request.Content.Length == 0) + { + return Failed(m_snapshot.Generation, "The document is empty."); + } + if (request.Content.Length > m_bounds.MaxDocumentBytes) + { + return new WotRegistryMutationResult( + WoTOutcomeEnum.Rejected, + null, + m_snapshot.Generation, + ImmutableArray.Create( + $"The document exceeds the maximum size of {m_bounds.MaxDocumentBytes} bytes."), + "Document too large."); + } + + string groupId = string.IsNullOrWhiteSpace(request.GroupId) + ? DefaultGroupFor(request.Kind) + : NormalizeSegment(request.GroupId!, nameof(request.GroupId)); + + // Copy the caller's buffer: the immutable snapshot owns the bytes. + byte[] content = request.Content.ToArray(); + + // Light parse to derive the kind/id/title and to record a format + // failure state for a document that cannot even be parsed. Full WoT + // validation and projection are performed by the coordinator. + string? thingId = null; + string? title = null; + WoTValidationOutcomeDataType? validation = null; + var diagnostics = ImmutableArray.CreateBuilder(); + bool parseFailed = false; + try + { + var options = new WotNodeSetConverterOptions + { + MaxJsonDocumentSize = m_bounds.MaxDocumentBytes, + MaxJsonDepth = m_bounds.MaxJsonDepth + }; + using WotDocument document = WotDocument.Parse(content, options); + thingId = document.Id; + title = document.Title; + } + catch (Exception ex) when (ex is JsonException or FormatException) + { + parseFailed = true; + diagnostics.Add($"Document parse failed: {ex.Message}"); + validation = FailedValidation(ex.Message); + } + + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResourceGroup? group = snapshot.FindGroup(groupId); + if (group is null) + { + // Implicit group creation on upsert must enforce MaxGroups the + // same way this method already enforces MaxResourcesPerGroup: + // reject the request rather than silently exceeding the bound. + if (snapshot.Groups.Count >= m_bounds.MaxGroups) + { + return new WotRegistryMutationResult( + WoTOutcomeEnum.Rejected, + null, + snapshot.Generation, + ImmutableArray.Create( + $"The registry already holds the maximum of {m_bounds.MaxGroups} groups."), + "Too many groups."); + } + group = new WotResourceGroup(groupId, request.Kind, epoch: snapshot.Generation + 1); + } + + string resourceId = DeriveResourceId(request, thingId, title); + WotResource? existing = group.Resources.TryGetValue( + resourceId, out WotResource? found) ? found : null; + + if (existing is null && + group.Resources.Count >= m_bounds.MaxResourcesPerGroup) + { + return new WotRegistryMutationResult( + WoTOutcomeEnum.Rejected, + null, + snapshot.Generation, + ImmutableArray.Create( + $"Group '{groupId}' already holds the maximum of " + + $"{m_bounds.MaxResourcesPerGroup} resources."), + "Too many resources."); + } + + byte[] digest = WotContentDigest.Compute(content); + + // Idempotency: an unchanged default document returns Unchanged + // and produces no new version and no model change. + if (existing?.DefaultVersion is { } current && + WotContentDigest.Equal(current.Digest, digest) && + !parseFailed) + { + return new WotRegistryMutationResult( + WoTOutcomeEnum.Unchanged, + existing, + snapshot.Generation, + ImmutableArray.Empty, + "Content digest unchanged."); + } + + long generation = snapshot.Generation + 1; + DateTime now = DateTime.UtcNow; + string versionId = NextVersionId(existing); + var version = new WotResourceVersion( + versionId, + content, + request.ContentType, + request.Format, + createdAt: now, + modifiedAt: now, + digest: digest); + + ImmutableArray versions = existing is null + ? ImmutableArray.Create(version) + : Trim(existing.Versions.Add(version), m_bounds.MaxVersionsPerResource); + + string? defaultVersionId = request.SetAsDefault + ? versionId + : existing?.DefaultVersionId ?? versionId; + + WoTLoadStateEnum loadState = parseFailed + ? WoTLoadStateEnum.Failed + : WoTLoadStateEnum.Unloaded; + + WotResource resource = existing is null + ? new WotResource( + groupId, + resourceId, + request.Kind, + versions, + defaultVersionId: defaultVersionId, + desiredVersionId: request.SetAsDefault ? versionId : null, + enabled: true, + loadState: loadState, + validation: validation, + diagnostics: diagnostics.ToImmutable(), + epoch: generation, + name: request.Name ?? title ?? resourceId, + description: request.Description, + thingId: thingId, + title: title) + : existing.With( + versions: versions, + defaultVersionId: defaultVersionId, + desiredVersionId: request.SetAsDefault ? versionId : existing.DesiredVersionId, + loadState: loadState, + validation: validation, + clearValidation: validation is null, + diagnostics: diagnostics.ToImmutable(), + epoch: generation, + name: request.Name ?? existing.Name, + description: request.Description ?? existing.Description, + thingId: thingId ?? existing.ThingId, + title: title ?? existing.Title); + + WotRegistrySnapshot next = ReplaceResource(snapshot, group, resource, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { resource.Xid }, projectionOnly: false); + + WoTOutcomeEnum outcome = parseFailed + ? WoTOutcomeEnum.Warning + : WoTOutcomeEnum.Success; + return new WotRegistryMutationResult( + outcome, resource, generation, diagnostics.ToImmutable()); + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask DeleteResourceAsync( + string groupId, + string resourceId, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResource? resource = snapshot.FindResource(groupId, resourceId); + if (resource is null) + { + return Failed(snapshot.Generation, "Resource not found."); + } + if (expectedEpoch is { } epoch && epoch != resource.Epoch) + { + return Rejected(snapshot.Generation, "Epoch mismatch."); + } + + long generation = snapshot.Generation + 1; + WotResourceGroup group = snapshot.FindGroup(groupId)!; + WotResourceGroup nextGroup = group.WithResources( + group.Resources.Remove(resourceId), generation); + WotRegistrySnapshot next = snapshot.WithGroup(nextGroup, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { resource.Xid }, projectionOnly: false); + return new WotRegistryMutationResult( + WoTOutcomeEnum.Success, resource, generation, ImmutableArray.Empty); + } + finally + { + m_mutex.Release(); + } + } + + /// + public ValueTask SetDefaultVersionAsync( + string groupId, + string resourceId, + string versionId, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + return MutateResourceAsync( + groupId, + resourceId, + expectedEpoch, + (resource, generation) => + { + if (resource.FindVersion(versionId) is null) + { + return (null, Rejected(generation - 1, $"Version '{versionId}' not found.")); + } + WotResource updated = resource.With( + defaultVersionId: versionId, + desiredVersionId: versionId, + epoch: generation); + return (updated, null); + }, + cancellationToken); + } + + /// + public ValueTask SetEnabledAsync( + string groupId, + string resourceId, + bool enabled, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + return MutateResourceAsync( + groupId, + resourceId, + expectedEpoch, + (resource, generation) => + { + if (resource.Enabled == enabled) + { + return (resource.With(epoch: generation), null); + } + WotResource updated = resource.With(enabled: enabled, epoch: generation); + return (updated, null); + }, + cancellationToken); + } + + /// + public async ValueTask AddRegistryLabelAsync( + string key, + string value, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + WotLabelValidator.Validate(key, value, m_bounds); + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + if (expectedEpoch is { } epoch && epoch != snapshot.Generation) + { + return Rejected(snapshot.Generation, "Epoch mismatch."); + } + if (!snapshot.Labels.ContainsKey(key) && + snapshot.Labels.Count >= m_bounds.MaxLabelsPerEntity) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"The registry already holds the maximum of " + + $"{m_bounds.MaxLabelsPerEntity} labels."); + } + long generation = snapshot.Generation + 1; + WotRegistrySnapshot next = snapshot.WithLabels( + snapshot.Labels.SetItem(key, value), generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, Array.Empty(), projectionOnly: true); + return new WotRegistryMutationResult( + WoTOutcomeEnum.Success, null, generation, ImmutableArray.Empty); + } + finally + { + m_mutex.Release(); + } + } + + /// + public async ValueTask RemoveRegistryLabelAsync( + string key, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(key)) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, "The Key argument is required."); + } + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + if (expectedEpoch is { } epoch && epoch != snapshot.Generation) + { + return Rejected(snapshot.Generation, "Epoch mismatch."); + } + if (!snapshot.Labels.ContainsKey(key)) + { + return Failed(snapshot.Generation, $"Label '{key}' not found."); + } + long generation = snapshot.Generation + 1; + WotRegistrySnapshot next = snapshot.WithLabels( + snapshot.Labels.Remove(key), generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, Array.Empty(), projectionOnly: true); + return new WotRegistryMutationResult( + WoTOutcomeEnum.Success, null, generation, ImmutableArray.Empty); + } + finally + { + m_mutex.Release(); + } + } + + /// + public ValueTask AddGroupLabelAsync( + string groupId, + string key, + string value, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + WotLabelValidator.Validate(key, value, m_bounds); + return MutateGroupAsync( + groupId, + expectedEpoch, + (group, generation) => + { + if (!group.Labels.ContainsKey(key) && + group.Labels.Count >= m_bounds.MaxLabelsPerEntity) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"Group '{groupId}' already holds the maximum of " + + $"{m_bounds.MaxLabelsPerEntity} labels."); + } + WotResourceGroup updated = group.WithLabels( + group.Labels.SetItem(key, value), generation); + return (updated, null); + }, + cancellationToken, + projectionOnly: true); + } + + /// + public ValueTask RemoveGroupLabelAsync( + string groupId, + string key, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(key)) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, "The Key argument is required."); + } + return MutateGroupAsync( + groupId, + expectedEpoch, + (group, generation) => + { + if (!group.Labels.ContainsKey(key)) + { + return (null, Failed(generation - 1, $"Label '{key}' not found.")); + } + WotResourceGroup updated = group.WithLabels( + group.Labels.Remove(key), generation); + return (updated, null); + }, + cancellationToken, + projectionOnly: true); + } + + /// + public ValueTask AddResourceLabelAsync( + string groupId, + string resourceId, + string key, + string value, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + WotLabelValidator.Validate(key, value, m_bounds); + return MutateResourceAsync( + groupId, + resourceId, + expectedEpoch, + (resource, generation) => + { + if (!resource.Labels.ContainsKey(key) && + resource.Labels.Count >= m_bounds.MaxLabelsPerEntity) + { + throw new ServiceResultException( + StatusCodes.BadTooManyOperations, + $"Resource '{resourceId}' already holds the maximum of " + + $"{m_bounds.MaxLabelsPerEntity} labels."); + } + WotResource updated = resource.With( + labels: resource.Labels.SetItem(key, value), epoch: generation); + return (updated, null); + }, + cancellationToken, + projectionOnly: true); + } + + /// + public ValueTask RemoveResourceLabelAsync( + string groupId, + string resourceId, + string key, + long? expectedEpoch = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(key)) + { + throw new ServiceResultException( + StatusCodes.BadInvalidArgument, "The Key argument is required."); + } + return MutateResourceAsync( + groupId, + resourceId, + expectedEpoch, + (resource, generation) => + { + if (!resource.Labels.ContainsKey(key)) + { + return (null, Failed(generation - 1, $"Label '{key}' not found.")); + } + WotResource updated = resource.With( + labels: resource.Labels.Remove(key), epoch: generation); + return (updated, null); + }, + cancellationToken, + projectionOnly: true); + } + + /// + public async ValueTask ApplyProjectionResultsAsync( + IReadOnlyList projections, + CancellationToken cancellationToken = default) + { + if (projections is null || projections.Count == 0) + { + return; + } + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + long generation = snapshot.Generation + 1; + var changed = new List(); + WotRegistrySnapshot next = snapshot; + foreach (WotResourceProjection projection in projections) + { + WotResource? resource = next.FindResource( + projection.GroupId, projection.ResourceId); + if (resource is null) + { + continue; + } + string? activeVersionId = projection.RetainPreviousActiveVersion + ? resource.ActiveVersionId + : projection.ActiveVersionId; + WotResource updated = resource.With( + activeVersionId: activeVersionId, + clearActiveVersion: activeVersionId is null, + loadState: projection.LoadState, + refreshGeneration: projection.RefreshGeneration, + materializedNodeCount: projection.MaterializedNodeCount, + rootNodeId: projection.RootNodeId, + clearRootNodeId: projection.RootNodeId is null, + validation: projection.Validation, + clearValidation: projection.Validation is null, + diagnostics: projection.Diagnostics, + lastRefreshTime: projection.LastRefreshTime, + epoch: resource.Epoch); + WotResourceGroup group = next.FindGroup(projection.GroupId)!; + next = ReplaceResource(next, group, updated, generation); + changed.Add(updated.Xid); + } + if (changed.Count == 0) + { + return; + } + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, changed, projectionOnly: true); + } + finally + { + m_mutex.Release(); + } + } + + /// + public void Dispose() + { + m_mutex.Dispose(); + } + + private async ValueTask MutateResourceAsync( + string groupId, + string resourceId, + long? expectedEpoch, + Func mutate, + CancellationToken cancellationToken, + bool projectionOnly = false) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResource? resource = snapshot.FindResource(groupId, resourceId); + if (resource is null) + { + return Failed(snapshot.Generation, "Resource not found."); + } + if (expectedEpoch is { } epoch && epoch != resource.Epoch) + { + return Rejected(snapshot.Generation, "Epoch mismatch."); + } + long generation = snapshot.Generation + 1; + (WotResource? updated, WotRegistryMutationResult? rejection) = mutate(resource, generation); + if (rejection is not null) + { + return rejection; + } + if (updated is null) + { + return Failed(snapshot.Generation, "Mutation produced no result."); + } + WotResourceGroup group = snapshot.FindGroup(groupId)!; + WotRegistrySnapshot next = ReplaceResource(snapshot, group, updated, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { updated.Xid }, projectionOnly); + return new WotRegistryMutationResult( + WoTOutcomeEnum.Success, updated, generation, ImmutableArray.Empty); + } + finally + { + m_mutex.Release(); + } + } + + private async ValueTask MutateGroupAsync( + string groupId, + long? expectedEpoch, + Func mutate, + CancellationToken cancellationToken, + bool projectionOnly = false) + { + await m_mutex.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_snapshot; + WotResourceGroup? group = snapshot.FindGroup(groupId); + if (group is null) + { + return Failed(snapshot.Generation, "Group not found."); + } + if (expectedEpoch is { } epoch && epoch != group.Epoch) + { + return Rejected(snapshot.Generation, "Epoch mismatch."); + } + long generation = snapshot.Generation + 1; + (WotResourceGroup? updated, WotRegistryMutationResult? rejection) = mutate(group, generation); + if (rejection is not null) + { + return rejection; + } + if (updated is null) + { + return Failed(snapshot.Generation, "Mutation produced no result."); + } + WotRegistrySnapshot next = snapshot.WithGroup(updated, generation); + await m_store.CommitAsync(next, cancellationToken).ConfigureAwait(false); + Volatile.Write(ref m_snapshot, next); + RaiseChanged(snapshot, next, new[] { updated.Xid }, projectionOnly); + return new WotRegistryMutationResult( + WoTOutcomeEnum.Success, null, generation, ImmutableArray.Empty); + } + finally + { + m_mutex.Release(); + } + } + + private static WotRegistrySnapshot ReplaceResource( + WotRegistrySnapshot snapshot, + WotResourceGroup group, + WotResource resource, + long generation) + { + WotResourceGroup nextGroup = group.WithResources( + group.Resources.SetItem(resource.ResourceId, resource), generation); + return snapshot.WithGroup(nextGroup, generation); + } + + private void RaiseChanged( + WotRegistrySnapshot previous, + WotRegistrySnapshot current, + IReadOnlyList changed, + bool projectionOnly) + { + Changed?.Invoke( + this, + new WotRegistryChangedEventArgs(previous, current, changed, projectionOnly)); + } + + private static ImmutableArray Trim( + ImmutableArray versions, + int max) + { + if (versions.Length <= max) + { + return versions; + } + // Drop the oldest versions beyond the retention bound. + return versions.RemoveRange(0, versions.Length - max); + } + + private static string NextVersionId(WotResource? existing) + { + long next = 1; + if (existing is not null) + { + foreach (WotResourceVersion version in existing.Versions) + { + if (long.TryParse( + version.VersionId, + NumberStyles.Integer, + CultureInfo.InvariantCulture, + out long value) && + value >= next) + { + next = value + 1; + } + } + } + return WotRegistrySnapshot.FormatVersionId(next); + } + + private static string DeriveResourceId( + WotUpsertResourceRequest request, + string? thingId, + string? title) + { + if (!string.IsNullOrWhiteSpace(request.ResourceId)) + { + return NormalizeSegment(request.ResourceId!, nameof(request.ResourceId)); + } + string candidate = thingId ?? request.Name ?? title ?? Guid.NewGuid().ToString("N"); + return Slugify(candidate); + } + + private static string DefaultGroupFor(WoTDocumentKindEnum kind) + => kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions; + + private static string NormalizeSegment(string value, string paramName) + { + if (string.IsNullOrWhiteSpace(value)) + { + throw new ArgumentException("A non-empty identifier is required.", paramName); + } + string slug = Slugify(value); + if (slug.Length == 0) + { + throw new ArgumentException( + $"'{value}' does not contain any identifier-safe characters.", paramName); + } + return slug; + } + + private static string Slugify(string value) + { + var builder = new StringBuilder(value.Length); + foreach (char c in value.Trim()) + { + if ((c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' || c == '.') + { + builder.Append(c); + } + else if (c >= 'A' && c <= 'Z') + { + builder.Append(char.ToLowerInvariant(c)); + } + else if (c is ' ' or ':' or '/' or '#') + { + builder.Append('-'); + } + } + string slug = builder.ToString().Trim('-', '.'); + return slug.Length == 0 ? Guid.NewGuid().ToString("N") : slug; + } + + private static WoTValidationOutcomeDataType FailedValidation(string reason) + { + return new WoTValidationOutcomeDataType + { + FormatValidated = true, + FormatOutcome = WoTOutcomeEnum.Failed, + FormatReason = reason, + CompatibilityValidated = false, + CompatibilityOutcome = WoTOutcomeEnum.Skipped, + ValidatedAt = DateTime.UtcNow, + VocabularyVersion = WotNodeSetConverter.VocabularyNamespace + }; + } + + private static WotRegistryMutationResult Failed(long generation, string message) + => new WotRegistryMutationResult( + WoTOutcomeEnum.Failed, null, generation, + ImmutableArray.Create(message), message); + + private static WotRegistryMutationResult Rejected(long generation, string message) + => new WotRegistryMutationResult( + WoTOutcomeEnum.Rejected, null, generation, + ImmutableArray.Create(message), message); + + private readonly IWotRegistryStore m_store; + private readonly WotRegistryPersistenceBounds m_bounds; + private readonly SemaphoreSlim m_mutex = new(1, 1); + private WotRegistrySnapshot m_snapshot; + } +} diff --git a/src/Opc.Ua.WotCon.Server/Registry/WotResourceFileManager.cs b/src/Opc.Ua.WotCon.Server/Registry/WotResourceFileManager.cs new file mode 100644 index 0000000000..15acce0174 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/Registry/WotResourceFileManager.cs @@ -0,0 +1,469 @@ +/* ======================================================================== + * 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.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Server.Registry +{ + /// + /// Manages the inherited OPC UA FileType primitives + /// (Open/Read/Write/Close/GetPosition/ + /// SetPosition) exposed by a single xRegistry ResourceType + /// document node in the WoT Connectivity 1.1 registry. + /// + /// + /// Generalizes the legacy WotAssetFileManager for the xRegistry + /// document surface: per-session handles, a bounded write buffer, a single + /// exclusive writer, and commit-on-close semantics. Read handles serve an + /// immutable snapshot of the resource's active/default version bytes; a write + /// handle buffers the upload and, when it is closed, commits the buffer as a + /// new version through the injected callback (which stores a validated or an + /// invalid version - the bytes are never lost). + /// + /// Per the xRegistry document model the only supported OpenFileMode + /// values are Read (1) and Write | EraseExisting (6); other + /// modes are rejected with . + /// + /// + internal sealed class WotResourceFileManager : IDisposable + { + public const byte ReadMode = 1; + public const byte WriteEraseMode = 6; + + public WotResourceFileManager( + FileState file, + int maxOpenHandles, + int maxDocumentSize, + Func authorizeWrite, + Func> onCommit) + { + m_file = file ?? throw new ArgumentNullException(nameof(file)); + m_maxHandles = maxOpenHandles; + m_maxSize = maxDocumentSize; + m_authorizeWrite = authorizeWrite ?? throw new ArgumentNullException(nameof(authorizeWrite)); + m_onCommit = onCommit ?? throw new ArgumentNullException(nameof(onCommit)); + + if (m_file.Writable is not null) + { + m_file.Writable.Value = true; + } + if (m_file.UserWritable is not null) + { + m_file.UserWritable.Value = true; + } + if (m_file.OpenCount is not null) + { + m_file.OpenCount.Value = 0; + } + if (m_file.MaxByteStringLength is not null) + { + m_file.MaxByteStringLength.Value = (uint)maxDocumentSize; + } + + if (m_file.Open is not null) + { + m_file.Open.OnCall = new OpenMethodStateMethodCallHandler(OnOpen); + } + if (m_file.Close is not null) + { + m_file.Close.OnCall = new CloseMethodStateMethodCallHandler(OnClose); + } + if (m_file.Read is not null) + { + m_file.Read.OnCall = new ReadMethodStateMethodCallHandler(OnRead); + } + if (m_file.Write is not null) + { + m_file.Write.OnCall = new WriteMethodStateMethodCallHandler(OnWrite); + } + if (m_file.GetPosition is not null) + { + m_file.GetPosition.OnCall = new GetPositionMethodStateMethodCallHandler(OnGetPosition); + } + if (m_file.SetPosition is not null) + { + m_file.SetPosition.OnCall = new SetPositionMethodStateMethodCallHandler(OnSetPosition); + } + } + + /// The current version bytes served to readers. + public byte[] CurrentContent { get; private set; } = Array.Empty(); + + /// + /// Replaces the served content (called when the registry snapshot changes). + /// + public void UpdatePersistedContent(byte[] content, string? mimeType) + { + CurrentContent = content ?? throw new ArgumentNullException(nameof(content)); + if (m_file.Size is not null) + { + m_file.Size.Value = (ulong)content.Length; + } + if (m_file.LastModifiedTime is not null) + { + m_file.LastModifiedTime.Value = DateTime.UtcNow; + } + if (mimeType is not null && m_file.MimeType is not null) + { + m_file.MimeType.Value = mimeType; + } + } + + /// + /// Opens an exclusive write handle for the supplied session without a + /// method call, returning the handle to a client that requested a file + /// upload as part of a create operation. + /// + public ServiceResult TryOpenWriteHandle(NodeId? sessionId, out uint fileHandle) + { + fileHandle = 0; + lock (m_handles) + { + if (m_handles.Count >= m_maxHandles) + { + return StatusCodes.BadTooManyOperations; + } + if (m_writingHandle != 0) + { + return ServiceResult.Create( + StatusCodes.BadInvalidState, "Another writer is already open on this file."); + } + fileHandle = ++m_nextHandle; + m_handles.Add(fileHandle, Handle.OpenWrite(sessionId)); + m_writingHandle = fileHandle; + if (m_file.OpenCount is not null) + { + m_file.OpenCount.Value = (ushort)m_handles.Count; + } + } + return ServiceResult.Good; + } + + public void Dispose() + { + lock (m_handles) + { + foreach (Handle handle in m_handles.Values) + { + handle.Dispose(); + } + m_handles.Clear(); + } + } + + private static NodeId? SessionIdOf(ISystemContext context) + => (context as ISessionSystemContext)?.SessionId; + + private ServiceResult OnOpen( + ISystemContext context, MethodState method, NodeId objectId, byte mode, ref uint fileHandle) + { + if (mode is not ReadMode and not WriteEraseMode) + { + return ServiceResult.Create(StatusCodes.BadNotSupported, + "A WoT document file only supports modes Read (1) and Write+EraseExisting (6)."); + } + if (mode == WriteEraseMode) + { + ServiceResult access = m_authorizeWrite(context, "OpenWrite"); + if (ServiceResult.IsBad(access)) + { + return access; + } + } + NodeId? sessionId = SessionIdOf(context); + lock (m_handles) + { + if (m_handles.Count >= m_maxHandles) + { + return StatusCodes.BadTooManyOperations; + } + if (mode == WriteEraseMode && m_writingHandle != 0) + { + return ServiceResult.Create(StatusCodes.BadInvalidState, + "Another writer is already open on this file."); + } + Handle handle = mode == WriteEraseMode + ? Handle.OpenWrite(sessionId) + : Handle.OpenRead(sessionId, CurrentContent); + fileHandle = ++m_nextHandle; + m_handles.Add(fileHandle, handle); + if (mode == WriteEraseMode) + { + m_writingHandle = fileHandle; + } + if (m_file.OpenCount is not null) + { + m_file.OpenCount.Value = (ushort)m_handles.Count; + } + } + return ServiceResult.Good; + } + + private ServiceResult OnClose( + ISystemContext context, MethodState method, NodeId objectId, uint fileHandle) + { + Handle handle; + bool commit; + lock (m_handles) + { + if (!TryGetHandleLocked(context, fileHandle, out handle, out ServiceResult err)) + { + return err; + } + commit = m_writingHandle == fileHandle; + if (commit) + { + ServiceResult access = m_authorizeWrite(context, "CloseWrite"); + if (ServiceResult.IsBad(access)) + { + m_handles.Remove(fileHandle); + m_writingHandle = 0; + if (m_file.OpenCount is not null) + { + m_file.OpenCount.Value = (ushort)m_handles.Count; + } + handle.Dispose(); + return access; + } + m_writingHandle = 0; + } + m_handles.Remove(fileHandle); + if (m_file.OpenCount is not null) + { + m_file.OpenCount.Value = (ushort)m_handles.Count; + } + } + + try + { + if (!commit) + { + return ServiceResult.Good; + } + byte[] content = ((MemoryStream)handle.Stream).ToArray(); + if (content.Length == 0) + { + // Nothing was written: closing a fresh writer is a no-op. + return ServiceResult.Good; + } + return m_onCommit(content, SessionIdOf(context), CancellationToken.None) + .AsTask().GetAwaiter().GetResult(); + } + finally + { + handle.Dispose(); + } + } + + private ServiceResult OnRead( + ISystemContext context, MethodState method, NodeId objectId, + uint fileHandle, int length, ref ByteString data) + { + lock (m_handles) + { + if (!TryGetHandleLocked(context, fileHandle, out Handle handle, out ServiceResult err)) + { + data = default; + return err; + } + if (handle.Writing) + { + data = default; + return ServiceResult.Create( + StatusCodes.BadInvalidState, "File handle is opened for writing."); + } + if (length <= 0) + { + data = ByteString.Empty; + return ServiceResult.Good; + } + int available = checked((int)(handle.Stream.Length - handle.Stream.Position)); + int toRead = Math.Min(available, length); + if (toRead <= 0) + { + data = ByteString.Empty; + return ServiceResult.Good; + } + byte[] buffer = new byte[toRead]; + int totalRead = 0; + while (totalRead < buffer.Length) + { + int n = handle.Stream.Read(buffer, totalRead, buffer.Length - totalRead); + if (n <= 0) + { + break; + } + totalRead += n; + } + if (totalRead != buffer.Length) + { + Array.Resize(ref buffer, totalRead); + } + data = ByteString.From(buffer); + } + return ServiceResult.Good; + } + + private ServiceResult OnWrite( + ISystemContext context, MethodState method, NodeId objectId, + uint fileHandle, ByteString data) + { + lock (m_handles) + { + if (!TryGetHandleLocked(context, fileHandle, out Handle handle, out ServiceResult err)) + { + return err; + } + if (!handle.Writing) + { + return ServiceResult.Create( + StatusCodes.BadInvalidState, "File handle is opened for reading."); + } + ServiceResult access = m_authorizeWrite(context, "Write"); + if (ServiceResult.IsBad(access)) + { + return access; + } + if (data.IsNull || data.Span.Length == 0) + { + return ServiceResult.Good; + } + ReadOnlySpan bytes = data.Span; + if (handle.Stream.Length + bytes.Length > m_maxSize) + { + return ServiceResult.Create(StatusCodes.BadOutOfMemory, + "The document exceeds the configured maximum size."); + } + byte[] copy = bytes.ToArray(); + handle.Stream.Write(copy, 0, copy.Length); + } + return ServiceResult.Good; + } + + private ServiceResult OnGetPosition( + ISystemContext context, MethodState method, NodeId objectId, + uint fileHandle, ref ulong position) + { + lock (m_handles) + { + if (!TryGetHandleLocked(context, fileHandle, out Handle handle, out ServiceResult err)) + { + return err; + } + position = (ulong)handle.Stream.Position; + } + return ServiceResult.Good; + } + + private ServiceResult OnSetPosition( + ISystemContext context, MethodState method, NodeId objectId, + uint fileHandle, ulong position) + { + lock (m_handles) + { + if (!TryGetHandleLocked(context, fileHandle, out Handle handle, out ServiceResult err)) + { + return err; + } + if (handle.Writing) + { + ServiceResult access = m_authorizeWrite(context, "SetWritePosition"); + if (ServiceResult.IsBad(access)) + { + return access; + } + } + if (position > (ulong)handle.Stream.Length) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "Requested position exceeds file length."); + } + handle.Stream.Position = (long)position; + } + return ServiceResult.Good; + } + + private bool TryGetHandleLocked( + ISystemContext context, uint fileHandle, out Handle handle, out ServiceResult error) + { + if (!m_handles.TryGetValue(fileHandle, out Handle? located)) + { + handle = null!; + error = ServiceResult.Create(StatusCodes.BadInvalidArgument, "Unknown file handle."); + return false; + } + NodeId? expected = SessionIdOf(context); + if (expected != null && located.SessionId != null && located.SessionId != expected) + { + handle = null!; + error = ServiceResult.Create( + StatusCodes.BadUserAccessDenied, "File handle is owned by another session."); + return false; + } + handle = located; + error = ServiceResult.Good; + return true; + } + + private sealed class Handle : IDisposable + { + private Handle(NodeId? sessionId, Stream stream, bool writing) + { + SessionId = sessionId; + Stream = stream; + Writing = writing; + } + + public NodeId? SessionId { get; } + public Stream Stream { get; } + public bool Writing { get; } + + public static Handle OpenRead(NodeId? sessionId, byte[] snapshot) + => new(sessionId, new MemoryStream(snapshot, writable: false), writing: false); + + public static Handle OpenWrite(NodeId? sessionId) + => new(sessionId, new MemoryStream(), writing: true); + + public void Dispose() => Stream.Dispose(); + } + + private readonly FileState m_file; + private readonly int m_maxHandles; + private readonly int m_maxSize; + private readonly Func m_authorizeWrite; + private readonly Func> m_onCommit; + private readonly Dictionary m_handles = new(); + private uint m_nextHandle; + private uint m_writingHandle; + } +} diff --git a/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotActionMapper.cs b/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotActionMapper.cs index 320d99f3e8..f2019609dd 100644 --- a/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotActionMapper.cs +++ b/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotActionMapper.cs @@ -63,7 +63,7 @@ public static IReadOnlyList BuildArguments(WotActionSchema? schema) new Argument { Name = schema.Title ?? "value", - DataType = DataTypeIds.BaseDataType, + DataType = Ua.DataTypeIds.BaseDataType, ValueRank = ValueRanks.Scalar, Description = BuildSchemaDescription(schema) } @@ -90,7 +90,7 @@ private static Argument BuildMemberArgument(string name, WotActionMember member) if (!WotPropertyMapper.TryMapPrimitive(jsonType, out NodeId dataType)) { - dataType = DataTypeIds.BaseDataType; + dataType = Ua.DataTypeIds.BaseDataType; } return new Argument diff --git a/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotPropertyMapper.cs b/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotPropertyMapper.cs index 4644895277..01f8f40c54 100644 --- a/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotPropertyMapper.cs +++ b/src/Opc.Ua.WotCon.Server/ThingDescriptions/WotPropertyMapper.cs @@ -71,7 +71,7 @@ public static bool TryMap( valueRank = ValueRanks.OneDimension; if (property.Items?.Type == null) { - dataType = DataTypeIds.BaseDataType; + dataType = Ua.DataTypeIds.BaseDataType; return true; } return TryMapPrimitive(property.Items.Type, out dataType); @@ -90,16 +90,16 @@ public static bool TryMapPrimitive(string? jsonType, out NodeId dataType) switch (jsonType?.ToLowerInvariant()) { case "boolean": - dataType = DataTypeIds.Boolean; + dataType = Ua.DataTypeIds.Boolean; return true; case "number": - dataType = DataTypeIds.Double; + dataType = Ua.DataTypeIds.Double; return true; case "integer": - dataType = DataTypeIds.Int64; + dataType = Ua.DataTypeIds.Int64; return true; case "string": - dataType = DataTypeIds.String; + dataType = Ua.DataTypeIds.String; return true; case null: case "": @@ -108,7 +108,7 @@ public static bool TryMapPrimitive(string? jsonType, out NodeId dataType) dataType = NodeId.Null; return false; default: - dataType = DataTypeIds.BaseDataType; + dataType = Ua.DataTypeIds.BaseDataType; return true; } } diff --git a/src/Opc.Ua.WotCon.Server/WotConModelPartition.cs b/src/Opc.Ua.WotCon.Server/WotConModelPartition.cs new file mode 100644 index 0000000000..db3eebc7e2 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotConModelPartition.cs @@ -0,0 +1,117 @@ +/* ======================================================================== + * 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/ + * ======================================================================*/ + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Splits the single combined WoT Connectivity 1.1 model + /// (http://opcfoundation.org/UA/WoT-Con/) into the two disjoint + /// static-node slices its NodeManagers own, so the deprecated 1.02 surface + /// and the additive registry surface are never claimed twice when both the + /// and the + /// operate on the same server. + /// + /// + /// The combined NodeSet incorporates the published OPC 10100-1 v1.02 model + /// at its original NodeIds (1..172, marked deprecated) plus the + /// additive registry nodes in the provisional 64000+ block. + /// Ownership is therefore decided by NodeId: the legacy asset manager owns + /// the incorporated 1.02 nodes and the registry manager owns the registry + /// nodes (together with the xRegistry base nodes it also loads). + /// + internal static class WotConModelPartition + { + /// + /// First NodeId of the additive registry block. NodeIds below this + /// value belong to the incorporated OPC 10100-1 v1.02 surface. + /// + public const uint FirstRegistryNodeId = 64000; + + /// + /// Ensures the xRegistry namespace is present so the combined model's + /// registry nodes (which reference xRegistry base types while being + /// created) can be instantiated before the registry slice is removed. + /// The legacy manager does not own the xRegistry namespace; it merely + /// needs the URI registered so resolves during predefined-node creation. + /// + public static void EnsureXRegistryNamespace(ISystemContext context) + { + context.NamespaceUris.GetIndexOrAppend(XRegistry.Namespaces.XRegistry); + } + + /// + /// Removes the additive registry nodes, retaining only the incorporated + /// OPC 10100-1 v1.02 surface for the legacy asset manager to own. + /// + public static NodeStateCollection RetainLegacyNodes( + NodeStateCollection nodes, ISystemContext context) + { + ushort modelNs = ModelNamespaceIndex(context); + for (int i = nodes.Count - 1; i >= 0; i--) + { + if (IsRegistryNode(nodes[i], modelNs)) + { + nodes.RemoveAt(i); + } + } + return nodes; + } + + /// + /// Removes the incorporated OPC 10100-1 v1.02 nodes, retaining only the + /// additive registry nodes (and any xRegistry base nodes already in the + /// collection) for the registry manager to own. + /// + public static NodeStateCollection RetainRegistryNodes( + NodeStateCollection nodes, ISystemContext context) + { + ushort modelNs = ModelNamespaceIndex(context); + for (int i = nodes.Count - 1; i >= 0; i--) + { + if (IsLegacyNode(nodes[i], modelNs)) + { + nodes.RemoveAt(i); + } + } + return nodes; + } + + private static ushort ModelNamespaceIndex(ISystemContext context) + => (ushort)context.NamespaceUris.GetIndex(Namespaces.WotCon); + + private static bool IsRegistryNode(NodeState node, ushort modelNs) + => node.NodeId.NamespaceIndex == modelNs && + node.NodeId.TryGetValue(out uint id) && id >= FirstRegistryNodeId; + + private static bool IsLegacyNode(NodeState node, ushort modelNs) + => node.NodeId.NamespaceIndex == modelNs && + node.NodeId.TryGetValue(out uint id) && id < FirstRegistryNodeId; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs b/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs index ccf88e736e..60524dba7e 100644 --- a/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs +++ b/src/Opc.Ua.WotCon.Server/WotConnectivityNodeManager.cs @@ -45,9 +45,12 @@ namespace Opc.Ua.WotCon.Server /// /// The static model nodes — WoTAssetConnectionManagement, all /// type definitions, and the HasWoTComponent reference type — - /// are loaded from the generated AddOpcUaWotCon table. Dynamic - /// nodes (assets, property variables, action methods) are created - /// per asset by in a dedicated namespace + /// are loaded from the generated AddOpcUaWotCon table, restricted to + /// the incorporated OPC 10100-1 v1.02 surface (NodeIds below the additive + /// registry block). The additive registry nodes in the same combined model + /// are owned by . Dynamic nodes (assets, + /// property variables, action methods) are created per asset by + /// in a dedicated namespace /// (). /// public sealed class WotConnectivityNodeManager : AsyncCustomNodeManager, INodeIdFactory @@ -170,7 +173,15 @@ protected override ValueTask LoadPredefinedNodesAsync( ISystemContext context, CancellationToken cancellationToken = default) { - return new ValueTask(new NodeStateCollection().AddOpcUaWotCon(context)); + // The combined WoT-Con model incorporates both the deprecated 1.02 + // surface and the additive registry nodes (which reference xRegistry + // base types). Register the xRegistry namespace so the combined table + // can be created, then keep only the 1.02 slice: the registry nodes + // are owned by WotRegistryNodeManager. + WotConModelPartition.EnsureXRegistryNamespace(context); + NodeStateCollection nodes = new NodeStateCollection().AddOpcUaWotCon(context); + WotConModelPartition.RetainLegacyNodes(nodes, context); + return new ValueTask(nodes); } /// diff --git a/src/Opc.Ua.WotCon.Server/WotConnectivityServerOptions.cs b/src/Opc.Ua.WotCon.Server/WotConnectivityServerOptions.cs index 582fbeb0a0..a767e08863 100644 --- a/src/Opc.Ua.WotCon.Server/WotConnectivityServerOptions.cs +++ b/src/Opc.Ua.WotCon.Server/WotConnectivityServerOptions.cs @@ -152,6 +152,24 @@ public sealed class WotConnectivityServerOptions /// public WotManagementAccessPolicy ManagementAccess { get; set; } = new WotManagementAccessPolicy(); + + /// + /// Optional bridge into the WoT Connectivity 1.1 registry. When set, a + /// legacy 1.02 asset's Thing Description is mirrored as a Thing + /// Description resource in whenever + /// the asset is (re)built, and removed when the asset is deleted, so + /// legacy assets participate in registry materialization without making the + /// flat V1 asset list canonical. Defaults to null (no bridge). + /// + public Registry.IWotRegistryService? RegistryBridge { get; set; } + + /// + /// The registry group id legacy assets are mirrored into when + /// is set. Defaults to the reserved + /// Thing Description group. + /// + public string RegistryBridgeGroupId { get; set; } + = Registry.WotRegistryGroups.ThingDescriptions; } /// @@ -161,7 +179,7 @@ public sealed class WotConnectivityServerOptions public sealed class WotConfigurationParameter { /// The OPC UA DataType for the parameter. - public NodeId DataType { get; init; } = DataTypeIds.String; + public NodeId DataType { get; init; } = Ua.DataTypeIds.String; /// The initial value (must be assignable to a Variant). public Variant? InitialValue { get; init; } diff --git a/src/Opc.Ua.WotCon.Server/WotManagementAccessPolicy.cs b/src/Opc.Ua.WotCon.Server/WotManagementAccessPolicy.cs index ae2a865da5..1ca223e1d7 100644 --- a/src/Opc.Ua.WotCon.Server/WotManagementAccessPolicy.cs +++ b/src/Opc.Ua.WotCon.Server/WotManagementAccessPolicy.cs @@ -66,7 +66,8 @@ public sealed class WotManagementAccessPolicy /// The minimum acceptable channel security mode. Defaults to /// ; channels /// using or - /// are rejected. + /// are rejected. A conformant + /// registry mutation surface shall not configure a weaker mode. /// public MessageSecurityMode MinimumSecurityMode { get; init; } = MessageSecurityMode.SignAndEncrypt; diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs new file mode 100644 index 0000000000..34b4039205 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManager.cs @@ -0,0 +1,518 @@ +/* ======================================================================== + * 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.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// The stable NodeManager that exposes the WoT Connectivity 1.1 registry + /// (WoTRegistry) and its xRegistry-derived group structure. It hosts + /// the injected and + /// : content mutations trigger a + /// coordinator refresh that projects TD/TM closures as separate runtime + /// NodeManagers, so this manager stays stable while projections come and go. + /// The generated Refresh Method is wired to the coordinator; the + /// coordinator's events are re-emitted as the generated registry event types. + /// + public sealed class WotRegistryNodeManager : AsyncCustomNodeManager + { + /// Initializes a new registry NodeManager. + public WotRegistryNodeManager( + IServerInternal server, + ApplicationConfiguration configuration, + WotRegistryServerOptions options, + IWotRegistryService registry, + WotMaterializationCoordinator coordinator) + : base( + server, + configuration, + server.Telemetry.CreateLogger(), + Namespaces.WotCon, + XRegistry.Namespaces.XRegistry) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + m_coordinator.StrictBindings = options.StrictBindings; + m_coordinator.RetirementPolicy = options.RetirementPolicy; + m_coordinator.ServerNamespaceUris = server.NamespaceUris; + m_projection = new WotRegistryProjection(this, m_registry, m_options, m_logger); + } + + /// Gets the hosted registry service. + public IWotRegistryService Registry => m_registry; + + /// Gets the hosted materialization coordinator. + public WotMaterializationCoordinator Coordinator => m_coordinator; + + /// + protected override ValueTask LoadPredefinedNodesAsync( + ISystemContext context, + CancellationToken cancellationToken = default) + { + // Load the xRegistry base plus the combined WoT-Con model, then keep + // only the additive registry slice. The incorporated (deprecated) + // OPC 10100-1 v1.02 nodes are owned by WotConnectivityNodeManager, so + // the two managers never claim the same static model node twice. + NodeStateCollection nodes = new NodeStateCollection() + .AddOpcUaXRegistry(context) + .AddOpcUaWotCon(context); + WotConModelPartition.RetainRegistryNodes(nodes, context); + return new ValueTask(nodes); + } + + /// + protected override ValueTask AddBehaviourToPredefinedNodeAsync( + ISystemContext context, + NodeState predefinedNode, + CancellationToken cancellationToken = default) + { + NodeId registryNodeId = ExpandedNodeId.ToNodeId( + ObjectIds.WoTRegistry, Server.NamespaceUris); + if (predefinedNode is BaseObjectState registry && + registry.NodeId == registryNodeId) + { + m_registryNode = registry; + registry.EventNotifier = EventNotifiers.SubscribeToEvents; + EnsureRegistryManagementMethods(context, registry); + WireRefreshMethod(registry); + ApplyRegistrySettings(context, registry); + } + return new ValueTask(predefinedNode); + } + + private void EnsureRegistryManagementMethods( + ISystemContext context, BaseObjectState registry) + { + if (registry is not RegistryState typed) + { + return; + } + // Instantiate the optional xRegistry CreateGroup/GetOrCreateGroup + // Methods on the well-known singleton. The generated Add helpers mint + // fresh per-instance NodeIds (through the NodeManager's NodeIdFactory) + // and rebase the argument references so the Methods never collide with + // the RegistryType Method declarations. + typed.AddCreateGroup(context); + typed.AddGetOrCreateGroup(context); + WotRegistryProjection.LinkMethodArguments(typed.CreateGroup, context); + WotRegistryProjection.LinkMethodArguments(typed.GetOrCreateGroup, context); + + // Instantiate the optional Labels (AttributesType) container and its + // AddAttribute/RemoveAttribute Methods here, before this predefined + // node's subtree is registered by the base class's + // CreateAddressSpaceAsync: only children present at that point are + // swept into the NodeManager's node table. WotRegistryProjection + // wires the actual Method handlers later (see AttachAsync). + typed.AddLabels(context); + if (typed.Labels is not null) + { + typed.Labels.AddAddAttribute(context); + typed.Labels.AddRemoveAttribute(context); + WotRegistryProjection.LinkMethodArguments(typed.Labels, context); + } + } + + /// + public override async ValueTask CreateAddressSpaceAsync( + IDictionary> externalReferences, + CancellationToken cancellationToken = default) + { + await base.CreateAddressSpaceAsync(externalReferences, cancellationToken) + .ConfigureAwait(false); + + // Chain WoTRegistry into the Server's notifier tree so its events + // reach subscribing clients. The generated WoTRegistry Object already + // declares the inverse HasNotifier to the Server object, so only the + // forward reference on the Server side is added here. + if (m_registryNode is not null) + { + if (externalReferences.TryGetValue( + Ua.ObjectIds.Server, out IList? serverRefs) || + (serverRefs = EnsureList(externalReferences, Ua.ObjectIds.Server)) != null) + { + serverRefs.Add(new NodeStateReference( + Ua.ReferenceTypeIds.HasNotifier, false, m_registryNode.NodeId)); + } + } + + await m_registry.InitializeAsync(cancellationToken).ConfigureAwait(false); + m_registry.Changed += OnRegistryChanged; + m_coordinator.Event += OnCoordinatorEvent; + + // Materialize the browseable group/resource projection, then project + // whatever is already persisted into the AddressSpace. + if (m_registryNode is not null) + { + await m_projection.AttachAsync(m_registryNode, cancellationToken) + .ConfigureAwait(false); + } + await SafeRefreshAsync("startup").ConfigureAwait(false); + await m_projection.ReconcileAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public override async ValueTask DeleteAddressSpaceAsync( + CancellationToken cancellationToken = default) + { + m_registry.Changed -= OnRegistryChanged; + m_coordinator.Event -= OnCoordinatorEvent; + await m_coordinator.RemoveAllAsync(cancellationToken).ConfigureAwait(false); + m_projection.Dispose(); + await base.DeleteAddressSpaceAsync(cancellationToken).ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing) + { + m_projection.Dispose(); + } + base.Dispose(disposing); + } + + private void WireRefreshMethod(BaseObjectState registry) + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon); + if (registry.FindChild(SystemContext, new QualifiedName(BrowseNames.Refresh, ns)) + is MethodState refresh) + { + refresh.OnCallMethod2Async = OnRefreshAsync; + } + } + + private void ApplyRegistrySettings(ISystemContext context, BaseObjectState registry) + { + SetChildValue(registry, "AutoRefresh", new Variant(m_options.AutoRefresh)); + SetChildValue(registry, "RefreshMode", + new Variant((int)WoTRefreshModeEnum.EventDriven)); + SetChildValue(registry, "VocabularyVersion", + new Variant(Opc.Ua.Wot.WotNodeSetConverter.VocabularyNamespace)); + ApplyBindingCapabilities(registry); + } + + private void ApplyBindingCapabilities(BaseObjectState registry) + { + IReadOnlyList caps = m_coordinator.BindingCapabilities; + if (caps.Count == 0) + { + return; + } + var encoded = new ExtensionObject[caps.Count]; + for (int i = 0; i < caps.Count; i++) + { + encoded[i] = new ExtensionObject(caps[i]); + } + SetChildValue(registry, "SelectedBindings", + new Variant(new ArrayOf(encoded))); + } + + private async ValueTask OnRefreshAsync( + ISystemContext context, + MethodState method, + NodeId objectId, + ArrayOf inputArguments, + List outputArguments, + CancellationToken cancellationToken) + { + ServiceResult access = CheckManagementAccess(context, "Refresh"); + if (ServiceResult.IsBad(access)) + { + return access; + } + + ServiceResult decoded = WotRefreshArguments.TryDecode( + inputArguments, Server.MessageContext, out WotRefreshRequest request); + if (ServiceResult.IsBad(decoded)) + { + return decoded; + } + + WotRefreshResult result = await m_coordinator + .RefreshAsync(request, cancellationToken).ConfigureAwait(false); + + outputArguments.Clear(); + outputArguments.Add(new Variant(new ExtensionObject(result.Summary))); + var encodedResults = new ExtensionObject[result.Results.Length]; + for (int i = 0; i < result.Results.Length; i++) + { + encodedResults[i] = new ExtensionObject(result.Results[i]); + } + outputArguments.Add(new Variant(new ArrayOf(encodedResults))); + outputArguments.Add(new Variant(result.NewGeneration)); + return ServiceResult.Good; + } + + private void OnRegistryChanged(object? sender, WotRegistryChangedEventArgs e) + { + // Keep the browseable projection synchronized on every change, + // including projection-only callbacks (which must never re-trigger + // materialization). + _ = SafeReconcileAsync(); + if (e.ProjectionOnly || !m_options.AutoRefresh) + { + return; + } + // Content mutation: re-project asynchronously without blocking the caller. + _ = SafeRefreshAsync("auto"); + } + + private void OnCoordinatorEvent(object? sender, WotMaterializationEventArgs e) + { + if (m_registryNode is null) + { + return; + } + try + { + NodeState source = EventSourceFor(e); + BaseEventState? evt = BuildEvent(e, source); + if (evt is not null) + { + source.ReportEvent(SystemContext, evt); + } + } + catch (Exception ex) + { + m_logger.LogWarning(ex, "Failed to report WoT materialization event."); + } + } + + private NodeState EventSourceFor(WotMaterializationEventArgs e) + { + // Resource lifecycle failures are sourced at the specific resource + // node; the registry object remains the summary source for the + // refresh-completed event. + if (e.Kind == WotMaterializationEventKind.RefreshCompleted) + { + return m_registryNode!; + } + return m_projection.EventSourceFor(e.Xid); + } + + private BaseEventState? BuildEvent(WotMaterializationEventArgs e, NodeState source) + { + switch (e.Kind) + { + case WotMaterializationEventKind.RefreshCompleted: + { + var evt = new WoTRefreshCompletedEventState(m_registryNode); + InitializeEvent(evt, source, "RefreshCompleted"); + // Summary/RequestId/NewGeneration come from the coordinator's + // refresh summary, which is produced from the registry snapshot. + if (e.Summary is not null) + { + SetEventStruct(evt, BrowseNames.Summary, e.Summary); + } + SetEventValue(evt, BrowseNames.RequestId, new Variant(e.RequestId)); + SetEventValue(evt, BrowseNames.Generation, new Variant(e.Generation)); + return evt; + } + case WotMaterializationEventKind.ValidationFailure: + { + var evt = new WoTValidationFailureEventState(source); + InitializeEvent(evt, source, "ValidationFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + if (e.Validation is not null) + { + SetEventStruct(evt, BrowseNames.ValidationOutcome, e.Validation); + } + return evt; + } + case WotMaterializationEventKind.LoadFailure: + { + var evt = new WoTLoadFailureEventState(source); + InitializeEvent(evt, source, "LoadFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + SetEventEnum(evt, BrowseNames.LoadState, e.LoadState); + SetEventValue( + evt, BrowseNames.FailedNodeId, new Variant(e.FailedNodeId ?? NodeId.Null)); + SetEventValue(evt, BrowseNames.Reason, new Variant(e.Reason)); + return evt; + } + case WotMaterializationEventKind.BindingFailure: + { + var evt = new WoTBindingFailureEventState(source); + InitializeEvent(evt, source, "BindingFailure: " + e.Reason); + PopulateResourceEventFields(evt, e); + SetEventValue(evt, BrowseNames.BindingUri, new Variant(e.BindingUri)); + SetEventValue(evt, BrowseNames.Reason, new Variant(e.Reason)); + return evt; + } + default: + { + var evt = new WoTResourceEventState(source); + InitializeEvent(evt, source, "Resource: " + e.ResourceId); + PopulateResourceEventFields(evt, e); + return evt; + } + } + } + + private void InitializeEvent(BaseEventState evt, NodeState source, string message) + { + evt.Initialize( + SystemContext, + source: source, + EventSeverity.Medium, + new LocalizedText(message)); + evt.SetChildValue( + SystemContext, Ua.BrowseNames.SourceName, + source.DisplayName.Text ?? "WoTRegistry", false); + } + + /// + /// Populates the identity/lifecycle fields shared by every + /// WoTResourceEventType (and its concrete subtypes) from the + /// coordinator's event arguments. + /// + private void PopulateResourceEventFields( + BaseEventState evt, WotMaterializationEventArgs e) + { + SetEventValue(evt, BrowseNames.Xid, new Variant(e.Xid)); + SetEventValue(evt, BrowseNames.ResourceId, new Variant(e.ResourceId)); + SetEventValue(evt, BrowseNames.VersionId, new Variant(e.VersionId)); + SetEventEnum(evt, BrowseNames.DocumentKind, e.DocumentKind); + SetEventValue(evt, BrowseNames.Generation, new Variant(e.Generation)); + SetEventEnum(evt, BrowseNames.Phase, e.Phase); + SetEventEnum(evt, BrowseNames.Outcome, e.Outcome); + } + + private void SetEventValue(BaseEventState evt, string browseName, Variant value) + => evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value, false); + + private void SetEventEnum(BaseEventState evt, string browseName, TEnum value) + where TEnum : struct, Enum + => evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value); + + private void SetEventStruct(BaseEventState evt, string browseName, TStruct value) + where TStruct : IEncodeable + => evt.SetChildValue(SystemContext, WoTQualifiedName(browseName), value, false); + + private QualifiedName WoTQualifiedName(string browseName) + => new(browseName, (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon)); + + private async Task SafeReconcileAsync() + { + try + { + await m_projection.ReconcileAsync(CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.LogWarning(ex, "WoT registry projection reconcile failed."); + } + } + + private async Task SafeRefreshAsync(string reason) + { + try + { + await m_coordinator.RefreshAsync(new WotRefreshRequest { RequestId = reason }) + .ConfigureAwait(false); + } + catch (Exception ex) + { + m_logger.LogWarning(ex, "WoT registry refresh ({Reason}) failed.", reason); + } + } + + internal ServiceResult CheckManagementAccess(ISystemContext context, string operation) + { + if (context is not SessionSystemContext { OperationContext: OperationContext operationContext }) + { + // Local / programmatic call: allowed. + return ServiceResult.Good; + } + WotManagementAccessPolicy policy = m_options.ManagementAccess; + MessageSecurityMode securityMode = operationContext.ChannelContext? + .EndpointDescription?.SecurityMode ?? MessageSecurityMode.None; + if (securityMode != policy.MinimumSecurityMode) + { + m_logger.LogWarning( + "Denied WoT registry '{Operation}': channel security mode {Mode} is too low.", + operation, securityMode); + return StatusCodes.BadUserAccessDenied; + } + IUserIdentity? identity = operationContext.UserIdentity; + if (identity is null || + (!policy.AllowAnonymous && identity.TokenType == UserTokenType.Anonymous)) + { + m_logger.LogWarning( + "Denied WoT registry '{Operation}': anonymous or missing identity.", operation); + return StatusCodes.BadUserAccessDenied; + } + if (!identity.GrantedRoleIds.Contains(policy.RequiredRoleId)) + { + m_logger.LogWarning( + "Denied WoT registry '{Operation}': caller lacks required role.", operation); + return StatusCodes.BadUserAccessDenied; + } + return ServiceResult.Good; + } + + private static IList EnsureList( + IDictionary> externalReferences, NodeId nodeId) + { + if (!externalReferences.TryGetValue(nodeId, out IList? list)) + { + list = new List(); + externalReferences[nodeId] = list; + } + return list; + } + + private void SetChildValue(BaseObjectState parent, string browseName, Variant value) + { + ushort ns = (ushort)Server.NamespaceUris.GetIndex(Namespaces.WotCon); + if (parent.FindChild(SystemContext, new QualifiedName(browseName, ns)) + is BaseVariableState variable) + { + variable.Value = value; + } + } + + private readonly WotRegistryServerOptions m_options; + private readonly IWotRegistryService m_registry; + private readonly WotMaterializationCoordinator m_coordinator; + private readonly WotRegistryProjection m_projection; + private BaseObjectState? m_registryNode; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs new file mode 100644 index 0000000000..10e4509dc5 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryNodeManagerFactory.cs @@ -0,0 +1,75 @@ +/* ======================================================================== + * 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 Opc.Ua.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// that produces the stable + /// configured with the shared registry + /// service and materialization coordinator. + /// + public sealed class WotRegistryNodeManagerFactory : INodeManagerFactory + { + /// Creates a new factory. + public WotRegistryNodeManagerFactory( + WotRegistryServerOptions options, + IWotRegistryService registry, + WotMaterializationCoordinator coordinator) + { + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_coordinator = coordinator ?? throw new ArgumentNullException(nameof(coordinator)); + } + + /// + public ArrayOf NamespacesUris => new string[] + { + Namespaces.WotCon, + XRegistry.Namespaces.XRegistry + }; + + /// + public INodeManager Create(IServerInternal server, ApplicationConfiguration configuration) + { +#pragma warning disable CA2000 // Ownership transfers to the MasterNodeManager. + return new WotRegistryNodeManager( + server, configuration, m_options, m_registry, m_coordinator).SyncNodeManager; +#pragma warning restore CA2000 + } + + private readonly WotRegistryServerOptions m_options; + private readonly IWotRegistryService m_registry; + private readonly WotMaterializationCoordinator m_coordinator; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs b/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs new file mode 100644 index 0000000000..1ff124b0c3 --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryProjection.cs @@ -0,0 +1,1165 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.XRegistry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Materializes the WoT Connectivity 1.1 registry snapshot as browseable + /// xRegistry Objects beneath the stable WoTRegistry node: + /// ThingDescriptionGroupType/ThingModelGroupType group Objects + /// and their ThingDescriptionFileType/ThingModelFileType + /// document resources. Every group and resource is (re)created, updated and + /// removed to mirror the immutable snapshot, with deterministic NodeIds + /// derived from the registry Xid, notifier references up the + /// registry → group → resource chain, and the xRegistry CRUD / + /// FileType / document Methods wired to the injected registry service. + /// + internal sealed class WotRegistryProjection : IDisposable + { + public WotRegistryProjection( + WotRegistryNodeManager manager, + IWotRegistryService registry, + WotRegistryServerOptions options, + ILogger logger) + { + m_manager = manager ?? throw new ArgumentNullException(nameof(manager)); + m_registry = registry ?? throw new ArgumentNullException(nameof(registry)); + m_options = options ?? throw new ArgumentNullException(nameof(options)); + m_logger = logger; + m_modelNs = (ushort)manager.Server.NamespaceUris.GetIndex(Namespaces.WotCon); + } + + /// + /// Binds the projection to the well-known registry Object, wires the + /// registry-level CreateGroup/GetOrCreateGroup Methods, materializes + /// and wires its Labels (AttributesType) container, and performs the + /// first reconcile. + /// + public async ValueTask AttachAsync(BaseObjectState registryNode, CancellationToken ct) + { + m_registryNode = registryNode ?? throw new ArgumentNullException(nameof(registryNode)); + registryNode.EventNotifier = EventNotifiers.SubscribeToEvents; + WireMethod(registryNode, XRegistry.BrowseNames.CreateGroup, OnCreateGroupAsync); + WireMethod(registryNode, XRegistry.BrowseNames.GetOrCreateGroup, OnGetOrCreateGroupAsync); + if (registryNode is RegistryState registryTyped) + { + registryTyped.AddLabels(m_manager.SystemContext); + WireLabelsContainer( + registryTyped.Labels, OnAddRegistryLabelAsync, OnRemoveRegistryLabelAsync); + LinkMethodArguments(registryTyped.Labels, m_manager.SystemContext); + } + await ReconcileAsync(ct).ConfigureAwait(false); + } + + /// + /// Finds the browseable resource node used as an event source, or the + /// registry node when the resource is unknown. + /// + public NodeState EventSourceFor(string? xid) + { + if (!string.IsNullOrEmpty(xid) && + m_resourcesByXid.TryGetValue(xid!, out WoTDocumentState? node)) + { + return node; + } + return m_registryNode!; + } + + /// + /// Reconciles the browseable projection with the current registry + /// snapshot: creates, updates and removes group and resource nodes. + /// Never re-triggers materialization. + /// + public async ValueTask ReconcileAsync(CancellationToken ct) + { + if (m_registryNode is null) + { + return; + } + await m_gate.WaitAsync(ct).ConfigureAwait(false); + try + { + WotRegistrySnapshot snapshot = m_registry.Current; + + if (m_registryNode is RegistryState registryTyped && registryTyped.Labels is not null) + { + await SyncLabelPropertiesAsync( + registryTyped.Labels, RegistryNodeIdPath, snapshot.Labels, ct) + .ConfigureAwait(false); + } + + var seenGroups = new HashSet(StringComparer.Ordinal); + foreach (WotResourceGroup group in snapshot.Groups.Values) + { + seenGroups.Add(group.GroupId); + if (!m_groups.TryGetValue(group.GroupId, out GroupEntry? entry)) + { + entry = await CreateGroupNodeAsync(group, ct).ConfigureAwait(false); + m_groups[group.GroupId] = entry; + } + else + { + ApplyGroupProperties(entry.Node, group); + if (entry.Node.Labels is not null) + { + await SyncLabelPropertiesAsync( + entry.Node.Labels, GroupNodeIdPath(group.GroupId), group.Labels, ct) + .ConfigureAwait(false); + } + entry.Node.ClearChangeMasks(m_manager.SystemContext, includeChildren: true); + } + + var seenResources = new HashSet(StringComparer.Ordinal); + foreach (WotResource resource in group.Resources.Values) + { + seenResources.Add(resource.ResourceId); + if (!entry.Resources.TryGetValue(resource.ResourceId, out ResourceEntry? res)) + { + res = await CreateResourceNodeAsync(entry, resource, ct) + .ConfigureAwait(false); + entry.Resources[resource.ResourceId] = res; + } + else + { + ApplyResourceProperties(res, resource); + if (res.Node.Labels is not null) + { + await SyncLabelPropertiesAsync( + res.Node.Labels, + ResourceNodeIdPath(resource.GroupId, resource.ResourceId), + resource.Labels, + ct).ConfigureAwait(false); + } + res.Node.ClearChangeMasks(m_manager.SystemContext, includeChildren: true); + } + } + + foreach (string resourceId in entry.Resources.Keys + .Where(id => !seenResources.Contains(id)).ToList()) + { + await RemoveResourceNodeAsync(entry, resourceId, ct).ConfigureAwait(false); + } + } + + foreach (string groupId in m_groups.Keys + .Where(id => !seenGroups.Contains(id)).ToList()) + { + await RemoveGroupNodeAsync(groupId, ct).ConfigureAwait(false); + } + } + finally + { + m_gate.Release(); + } + } + + public void Dispose() + { + if (m_disposed) + { + return; + } + m_disposed = true; + foreach (GroupEntry group in m_groups.Values) + { + foreach (ResourceEntry resource in group.Resources.Values) + { + resource.File?.Dispose(); + } + } + m_groups.Clear(); + m_resourcesByXid.Clear(); + m_gate.Dispose(); + } + + // ---- group nodes ------------------------------------------------- + + private async ValueTask CreateGroupNodeAsync( + WotResourceGroup group, CancellationToken ct) + { + bool tm = group.Kind == WoTDocumentKindEnum.ThingModel; + GroupState node = tm + ? new ThingModelGroupState(m_registryNode) + : new ThingDescriptionGroupState(m_registryNode); + NodeId nodeId = GroupNodeId(group.GroupId); + node.ReferenceTypeId = Ua.ReferenceTypeIds.Organizes; + node.TypeDefinitionId = ExpandedNodeId.ToNodeId( + tm ? ObjectTypeIds.ThingModelGroupType : ObjectTypeIds.ThingDescriptionGroupType, + m_manager.Server.NamespaceUris); + node.Create( + m_manager.SystemContext, nodeId, + new QualifiedName(group.GroupId, m_modelNs), new LocalizedText(group.Name), + assignNodeIds: false); + + node.AddCreateResource(m_manager.SystemContext); + node.AddGetOrCreateResource(m_manager.SystemContext); + node.AddDelete(m_manager.SystemContext); + node.AddXid(m_manager.SystemContext); + node.AddEpoch(m_manager.SystemContext); + node.AddName(m_manager.SystemContext); + node.AddDescription(m_manager.SystemContext); + node.AddCreatedAt(m_manager.SystemContext); + node.AddModifiedAt(m_manager.SystemContext); + node.AddLabels(m_manager.SystemContext); + node.EventNotifier = EventNotifiers.SubscribeToEvents; + + string groupId = group.GroupId; + WoTDocumentKindEnum kind = group.Kind; + if (node.CreateResource is not null) + { + node.CreateResource.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnCreateResourceAsync(groupId, kind, c, i, ot, t); + } + if (node.GetOrCreateResource is not null) + { + node.GetOrCreateResource.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnGetOrCreateResourceAsync(groupId, kind, c, i, ot, t); + } + if (node.Delete is not null) + { + node.Delete.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnDeleteGroupAsync(groupId, c, i, t); + } + WireLabelsContainer( + node.Labels, + (c, i, t) => OnAddGroupLabelAsync(groupId, c, i, t), + (c, i, t) => OnRemoveGroupLabelAsync(groupId, c, i, t)); + + ApplyGroupProperties(node, group); + DateTime createdAt = DateTime.UtcNow; + SetValue(node.CreatedAt, (DateTimeUtc)createdAt); + SetValue(node.ModifiedAt, (DateTimeUtc)createdAt); + m_manager.SystemContext.AssignInstanceChildNodeIds(node); + LinkMethodArguments(node, m_manager.SystemContext); + + m_registryNode!.AddChild(node); + m_registryNode.AddReference(Ua.ReferenceTypeIds.HasNotifier, false, nodeId); + node.AddReference(Ua.ReferenceTypeIds.HasNotifier, true, m_registryNode.NodeId); + + await m_manager.AddPredefinedNodeAsync(node, ct).ConfigureAwait(false); + var entry = new GroupEntry(node, group.Kind); + await SyncLabelPropertiesAsync( + node.Labels!, GroupNodeIdPath(group.GroupId), group.Labels, ct).ConfigureAwait(false); + return entry; + } + + private void ApplyGroupProperties(GroupState node, WotResourceGroup group) + { + SetValue(node.GroupId, group.GroupId); + SetValue(node.Xid, group.Xid); + SetValue(node.Epoch, (uint)group.Epoch); + SetValue(node.Name, group.Name); + SetValue(node.Description, group.Description); + } + + private async ValueTask RemoveGroupNodeAsync(string groupId, CancellationToken ct) + { + if (!m_groups.TryGetValue(groupId, out GroupEntry? entry)) + { + return; + } + foreach (string resourceId in entry.Resources.Keys.ToList()) + { + await RemoveResourceNodeAsync(entry, resourceId, ct).ConfigureAwait(false); + } + m_registryNode!.RemoveReference(Ua.ReferenceTypeIds.HasNotifier, false, entry.Node.NodeId); + m_registryNode.RemoveChild(entry.Node); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, entry.Node.NodeId, ct) + .ConfigureAwait(false); + m_groups.Remove(groupId); + } + + // ---- resource nodes ---------------------------------------------- + + private async ValueTask CreateResourceNodeAsync( + GroupEntry group, WotResource resource, CancellationToken ct) + { + bool tm = resource.Kind == WoTDocumentKindEnum.ThingModel; + WoTDocumentState node = tm + ? new ThingModelFileState(group.Node) + : new ThingDescriptionFileState(group.Node); + NodeId nodeId = ResourceNodeId(resource.GroupId, resource.ResourceId); + node.ReferenceTypeId = Ua.ReferenceTypeIds.Organizes; + node.TypeDefinitionId = ExpandedNodeId.ToNodeId( + tm ? ObjectTypeIds.ThingModelFileType : ObjectTypeIds.ThingDescriptionFileType, + m_manager.Server.NamespaceUris); + node.Create( + m_manager.SystemContext, nodeId, + new QualifiedName(resource.ResourceId, m_modelNs), + new LocalizedText(resource.Name), assignNodeIds: false); + + // Optional xRegistry registry metadata children. + node.AddVersionId(m_manager.SystemContext); + node.AddFormat(m_manager.SystemContext); + node.AddContentType(m_manager.SystemContext); + node.AddXid(m_manager.SystemContext); + node.AddEpoch(m_manager.SystemContext); + node.AddName(m_manager.SystemContext); + node.AddDescription(m_manager.SystemContext); + node.AddCreatedAt(m_manager.SystemContext); + node.AddModifiedAt(m_manager.SystemContext); + node.AddDesiredVersionId(m_manager.SystemContext); + node.AddActiveVersionId(m_manager.SystemContext); + node.AddIsDefault(m_manager.SystemContext); + node.AddContentDigest(m_manager.SystemContext); + node.AddValidationOutcome(m_manager.SystemContext); + node.AddMaterializedNodeCount(m_manager.SystemContext); + node.AddRootNodeId(m_manager.SystemContext); + node.AddRefreshGeneration(m_manager.SystemContext); + node.AddLastRefreshTime(m_manager.SystemContext); + node.AddDelete(m_manager.SystemContext); + node.AddValidate(m_manager.SystemContext); + node.AddSetEnabled(m_manager.SystemContext); + node.AddSetDefaultVersion(m_manager.SystemContext); + node.AddLabels(m_manager.SystemContext); + node.EventNotifier = EventNotifiers.SubscribeToEvents; + + if (node is ThingDescriptionFileState td) + { + td.AddThingId(m_manager.SystemContext); + td.AddThingTitle(m_manager.SystemContext); + td.AddBaseUri(m_manager.SystemContext); + } + else if (node is ThingModelFileState tmNode) + { + tmNode.AddModelTitle(m_manager.SystemContext); + tmNode.AddModelVersion(m_manager.SystemContext); + tmNode.AddDerivedTypeNodeId(m_manager.SystemContext); + } + + string groupId = resource.GroupId; + string resourceId = resource.ResourceId; + WoTDocumentKindEnum kind = resource.Kind; + if (node.Delete is not null) + { + node.Delete.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnDeleteResourceAsync(groupId, resourceId, c, i, t); + } + if (node.Validate is not null) + { + node.Validate.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnValidateAsync(groupId, resourceId, c, ot, t); + } + if (node.SetEnabled is not null) + { + node.SetEnabled.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnSetEnabledAsync(groupId, resourceId, c, i, t); + } + if (node.SetDefaultVersion is not null) + { + node.SetDefaultVersion.OnCallMethod2Async = + (c, m, o, i, ot, t) => OnSetDefaultVersionAsync(groupId, resourceId, c, i, t); + } + WireLabelsContainer( + node.Labels, + (c, i, t) => OnAddResourceLabelAsync(groupId, resourceId, c, i, t), + (c, i, t) => OnRemoveResourceLabelAsync(groupId, resourceId, c, i, t)); + + // FileType transfer for the document body (commit-on-close). + var file = new WotResourceFileManager( + node, + m_options.Bounds.MaxOpenFileHandles, + m_options.Bounds.MaxDocumentBytes, + (context, operation) => m_manager.CheckManagementAccess(context, operation), + (bytes, session, token) => CommitDocumentAsync(groupId, resourceId, kind, bytes, token)); + + ApplyResourceProperties(new ResourceEntry(node, file, groupId, resourceId, kind), resource); + m_manager.SystemContext.AssignInstanceChildNodeIds(node); + LinkMethodArguments(node, m_manager.SystemContext); + + group.Node.AddChild(node); + group.Node.AddReference(Ua.ReferenceTypeIds.HasNotifier, false, nodeId); + node.AddReference(Ua.ReferenceTypeIds.HasNotifier, true, group.Node.NodeId); + + await m_manager.AddPredefinedNodeAsync(node, ct).ConfigureAwait(false); + m_resourcesByXid[BuildXid(resource.GroupId, resource.ResourceId)] = node; + var entry = new ResourceEntry(node, file, groupId, resourceId, kind); + await SyncLabelPropertiesAsync( + node.Labels!, ResourceNodeIdPath(groupId, resourceId), resource.Labels, ct) + .ConfigureAwait(false); + return entry; + } + + private void ApplyResourceProperties(ResourceEntry entry, WotResource resource) + { + WoTDocumentState node = entry.Node; + WotResourceVersion? version = resource.DefaultVersion; + WotResourceVersion? active = resource.ActiveVersion ?? version; + + SetValue(node.ResourceId, resource.ResourceId); + SetValue(node.VersionId, version?.VersionId ?? string.Empty); + SetValue(node.Format, version?.Format ?? string.Empty); + SetValue(node.ContentType, version?.ContentType ?? "application/td+json"); + SetValue(node.Xid, resource.Xid); + SetValue(node.Epoch, (uint)resource.Epoch); + SetValue(node.Name, resource.Name); + SetValue(node.Description, resource.Description); + if (version is not null) + { + SetValue(node.CreatedAt, (DateTimeUtc)version.CreatedAt); + } + SetValue(node.ModifiedAt, (DateTimeUtc)(version?.ModifiedAt ?? DateTime.UtcNow)); + + SetValue(node.DocumentKind, resource.Kind); + SetValue(node.Enabled, resource.Enabled); + SetValue(node.LoadState, resource.LoadState); + SetValue(node.DesiredVersionId, resource.DesiredVersionId ?? string.Empty); + SetValue(node.ActiveVersionId, resource.ActiveVersionId ?? string.Empty); + SetValue(node.IsDefault, version is not null && + string.Equals(version.VersionId, resource.DefaultVersionId, StringComparison.Ordinal)); + SetValue(node.ContentDigest, (ByteString)(version?.Digest ?? Array.Empty())); + if (resource.Validation is not null) + { + SetValue(node.ValidationOutcome, resource.Validation); + } + SetValue(node.MaterializedNodeCount, (uint)resource.MaterializedNodeCount); + SetValue(node.RootNodeId, resource.RootNodeId ?? NodeId.Null); + SetValue(node.RefreshGeneration, resource.RefreshGeneration); + SetValue(node.LastRefreshTime, (DateTimeUtc)resource.LastRefreshTime); + + if (node is ThingDescriptionFileState td) + { + SetValue(td.ThingId, resource.ThingId ?? string.Empty); + SetValue(td.ThingTitle, resource.Title ?? string.Empty); + } + else if (node is ThingModelFileState tmNode) + { + SetValue(tmNode.ModelTitle, resource.Title ?? string.Empty); + SetValue(tmNode.DerivedTypeNodeId, resource.RootNodeId ?? NodeId.Null); + } + + byte[] content = active?.Content.ToArray() ?? Array.Empty(); + entry.File?.UpdatePersistedContent(content, version?.ContentType); + } + + private async ValueTask RemoveResourceNodeAsync( + GroupEntry group, string resourceId, CancellationToken ct) + { + if (!group.Resources.TryGetValue(resourceId, out ResourceEntry? entry)) + { + return; + } + entry.File?.Dispose(); + m_resourcesByXid.TryRemove(BuildXid(entry.GroupId, entry.ResourceId), out _); + group.Node.RemoveReference(Ua.ReferenceTypeIds.HasNotifier, false, entry.Node.NodeId); + group.Node.RemoveChild(entry.Node); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, entry.Node.NodeId, ct) + .ConfigureAwait(false); + group.Resources.Remove(resourceId); + } + + // ---- method handlers --------------------------------------------- + + private async ValueTask OnCreateGroupAsync( + ISystemContext context, MethodState method, NodeId objectId, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "CreateGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? groupId = GetString(input, 0); + if (string.IsNullOrWhiteSpace(groupId)) + { + return StatusCodes.BadInvalidArgument; + } + WotResourceGroup? group = await m_registry + .TryCreateGroupAsync(groupId!, KindForGroup(groupId!), cancellationToken: ct) + .ConfigureAwait(false); + if (group is null) + { + return ServiceResult.Create( + StatusCodes.BadNodeIdExists, $"Group '{groupId}' already exists."); + } + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(GroupNodeId(group.GroupId))); + return ServiceResult.Good; + } + + private async ValueTask OnGetOrCreateGroupAsync( + ISystemContext context, MethodState method, NodeId objectId, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "GetOrCreateGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? groupId = GetString(input, 0); + if (string.IsNullOrWhiteSpace(groupId)) + { + return StatusCodes.BadInvalidArgument; + } + bool existed = m_registry.Current.FindGroup(NormalizeId(groupId!)) is not null; + WotResourceGroup group = await m_registry + .GetOrCreateGroupAsync(groupId!, KindForGroup(groupId!), cancellationToken: ct) + .ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(GroupNodeId(group.GroupId))); + output.Add(new Variant(!existed)); + return ServiceResult.Good; + } + + private async ValueTask OnCreateResourceAsync( + string groupId, WoTDocumentKindEnum kind, ISystemContext context, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "CreateResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? resourceId = GetString(input, 0); + bool requestOpen = GetBool(input, 2, false); + if (string.IsNullOrWhiteSpace(resourceId)) + { + return StatusCodes.BadInvalidArgument; + } + WotResource? resource = await m_registry + .TryCreateResourceAsync(groupId, resourceId!, kind, ct).ConfigureAwait(false); + if (resource is null) + { + return ServiceResult.Create( + StatusCodes.BadNodeIdExists, + $"Resource '{resourceId}' already exists in group '{groupId}'."); + } + await ReconcileAsync(ct).ConfigureAwait(false); + return CompleteResourceOutput( + resource.GroupId, resource.ResourceId, requestOpen, context, output, created: null); + } + + private async ValueTask OnGetOrCreateResourceAsync( + string groupId, WoTDocumentKindEnum kind, ISystemContext context, + ArrayOf input, List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "GetOrCreateResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? resourceId = GetString(input, 0); + bool requestOpen = GetBool(input, 2, false); + if (string.IsNullOrWhiteSpace(resourceId)) + { + return StatusCodes.BadInvalidArgument; + } + (WotResource resource, bool created) = await m_registry + .GetOrCreateResourceAsync(groupId, resourceId!, kind, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return CompleteResourceOutput( + resource.GroupId, resource.ResourceId, requestOpen, context, output, created); + } + + private ServiceResult CompleteResourceOutput( + string groupId, string resourceId, bool requestOpen, + ISystemContext context, List output, bool? created) + { + NodeId nodeId = ResourceNodeId(groupId, resourceId); + uint fileHandle = 0; + if (requestOpen && + m_groups.TryGetValue(groupId, out GroupEntry? group) && + group.Resources.TryGetValue(resourceId, out ResourceEntry? entry) && + entry.File is not null) + { + ServiceResult open = entry.File.TryOpenWriteHandle( + (context as ISessionSystemContext)?.SessionId, out fileHandle); + if (ServiceResult.IsBad(open)) + { + return open; + } + } + WotResource? resource = m_registry.Current.FindResource(groupId, resourceId); + output.Clear(); + output.Add(new Variant(nodeId)); + output.Add(new Variant(resource?.DefaultVersionId ?? string.Empty)); + output.Add(new Variant(fileHandle)); + if (created is { } wasCreated) + { + output.Add(new Variant(wasCreated)); + } + return ServiceResult.Good; + } + + private async ValueTask OnDeleteGroupAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "DeleteGroup"); + if (ServiceResult.IsBad(access)) + { + return access; + } + long? epoch = OptionalEpoch(input, 0); + WotRegistryMutationResult result = await m_registry + .DeleteGroupAsync(groupId, epoch, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnDeleteResourceAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "DeleteResource"); + if (ServiceResult.IsBad(access)) + { + return access; + } + long? epoch = OptionalEpoch(input, 0); + WotRegistryMutationResult result = await m_registry + .DeleteResourceAsync(groupId, resourceId, epoch, ct).ConfigureAwait(false); + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnValidateAsync( + string groupId, string resourceId, ISystemContext context, + List output, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "Validate"); + if (ServiceResult.IsBad(access)) + { + return access; + } + WoTValidationOutcomeDataType outcome; + try + { + outcome = await m_registry.ValidateResourceAsync(groupId, resourceId, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + output.Clear(); + output.Add(new Variant(new ExtensionObject(outcome))); + return ServiceResult.Good; + } + + private async ValueTask OnSetEnabledAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "SetEnabled"); + if (ServiceResult.IsBad(access)) + { + return access; + } + if (GetBoolOrNull(input, 0) is not { } enabled) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The Enabled argument is required."); + } + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result = await m_registry + .SetEnabledAsync(groupId, resourceId, enabled, epoch, ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnSetDefaultVersionAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "SetDefaultVersion"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? versionId = GetString(input, 0); + if (string.IsNullOrEmpty(versionId)) + { + return ServiceResult.Create( + StatusCodes.BadInvalidArgument, "The VersionId argument is required."); + } + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result = await m_registry + .SetDefaultVersionAsync(groupId, resourceId, versionId!, epoch, ct) + .ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask CommitDocumentAsync( + string groupId, string resourceId, WoTDocumentKindEnum kind, + byte[] content, CancellationToken ct) + { + var request = new WotUpsertResourceRequest + { + GroupId = groupId, + ResourceId = resourceId, + Kind = kind, + Content = content, + ContentType = kind == WoTDocumentKindEnum.ThingModel + ? "application/tm+json" + : "application/td+json", + Format = kind == WoTDocumentKindEnum.ThingModel ? "WoT-TM/1.1" : "WoT-TD/1.1", + SetAsDefault = true + }; + WotRegistryMutationResult result = await m_registry + .UpsertResourceAsync(request, ct).ConfigureAwait(false); + // A validation failure still stores the version (Warning): the bytes + // are never lost and the previous active projection is retained. + return result.Outcome == WoTOutcomeEnum.Rejected || result.Outcome == WoTOutcomeEnum.Failed + ? ServiceResult.Create(StatusCodes.BadInvalidState, result.Message) + : ServiceResult.Good; + } + + // ---- labels -------------------------------------------------------- + + /// + /// Wires the AddAttribute/RemoveAttribute Method handlers on a + /// materialized Labels (AttributesType) container, instantiating the + /// two optional Method children when not already present. + /// + private void WireLabelsContainer( + AttributesState? labels, + Func, CancellationToken, ValueTask> onAdd, + Func, CancellationToken, ValueTask> onRemove) + { + if (labels is null) + { + return; + } + labels.AddAddAttribute(m_manager.SystemContext); + labels.AddRemoveAttribute(m_manager.SystemContext); + if (labels.AddAttribute is not null) + { + labels.AddAttribute.OnCallMethod2Async = (c, m, o, i, ot, t) => onAdd(c, i, t); + } + if (labels.RemoveAttribute is not null) + { + labels.RemoveAttribute.OnCallMethod2Async = (c, m, o, i, ot, t) => onRemove(c, i, t); + } + } + + /// + /// Reconciles the browsable label Property children of a Labels + /// container against the desired dictionary: adds/updates changed + /// values, and removes labels no longer present. Ordinal enumeration + /// of keeps materialization order + /// deterministic. + /// + private async ValueTask SyncLabelPropertiesAsync( + AttributesState labels, + string basePath, + ImmutableSortedDictionary desired, + CancellationToken ct) + { + ISystemContext context = m_manager.SystemContext; + var existing = new Dictionary>(StringComparer.Ordinal); + var children = new List(); + labels.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + if (child is PropertyState property && property.BrowseName.Name is string name) + { + existing[name] = property; + } + } + + foreach (KeyValuePair label in desired) + { + if (existing.TryGetValue(label.Key, out PropertyState? property)) + { + if (!string.Equals(property.Value, label.Value, StringComparison.Ordinal)) + { + property.Value = label.Value; + property.ClearChangeMasks(context, includeChildren: false); + } + continue; + } + PropertyState created = labels.AddAttribute_Placeholder( + context, new QualifiedName(label.Key, m_modelNs)); + created.NodeId = LabelNodeId(basePath, label.Key); + created.Value = label.Value; + await m_manager.AddPredefinedNodeAsync(created, ct).ConfigureAwait(false); + } + + foreach (KeyValuePair> stale in existing + .Where(kv => !desired.ContainsKey(kv.Key)).ToList()) + { + labels.RemoveChild(stale.Value); + await m_manager.DeleteNodeAsync(m_manager.SystemContext, stale.Value.NodeId, ct) + .ConfigureAwait(false); + } + } + + private NodeId LabelNodeId(string basePath, string key) + => new NodeId($"{basePath}/labels/{key}", m_modelNs); + + private async ValueTask OnAddRegistryLabelAsync( + ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddRegistryLabelAsync(key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveRegistryLabelAsync( + ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveRegistryLabelAsync(key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnAddGroupLabelAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddGroupLabelAsync(groupId, key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveGroupLabelAsync( + string groupId, ISystemContext context, ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveGroupLabelAsync(groupId, key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnAddResourceLabelAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "AddAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + string value = GetString(input, 1) ?? string.Empty; + long? epoch = OptionalEpoch(input, 2); + WotRegistryMutationResult result; + try + { + result = await m_registry + .AddResourceLabelAsync(groupId, resourceId, key ?? string.Empty, value, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + private async ValueTask OnRemoveResourceLabelAsync( + string groupId, string resourceId, ISystemContext context, + ArrayOf input, CancellationToken ct) + { + ServiceResult access = m_manager.CheckManagementAccess(context, "RemoveAttribute"); + if (ServiceResult.IsBad(access)) + { + return access; + } + string? key = GetString(input, 0); + long? epoch = OptionalEpoch(input, 1); + WotRegistryMutationResult result; + try + { + result = await m_registry + .RemoveResourceLabelAsync(groupId, resourceId, key ?? string.Empty, epoch, ct) + .ConfigureAwait(false); + } + catch (ServiceResultException ex) + { + return ex.Result; + } + await ReconcileAsync(ct).ConfigureAwait(false); + return ToServiceResult(result); + } + + // ---- helpers ----------------------------------------------------- + + private WoTDocumentKindEnum KindForGroup(string groupId) + => string.Equals(NormalizeId(groupId), WotRegistryGroups.ThingModels, StringComparison.Ordinal) + ? WoTDocumentKindEnum.ThingModel + : WoTDocumentKindEnum.ThingDescription; + + private static string NormalizeId(string id) + => id.Trim().ToLowerInvariant(); + + private static string BuildXid(string groupId, string resourceId) + => $"/groups/{groupId}/resources/{resourceId}"; + + private const string RegistryNodeIdPath = "WoTRegistry"; + + private static string GroupNodeIdPath(string groupId) + => "WoTRegistry/groups/" + groupId; + + private static string ResourceNodeIdPath(string groupId, string resourceId) + => $"WoTRegistry/groups/{groupId}/resources/{resourceId}"; + + private NodeId GroupNodeId(string groupId) + => new NodeId(GroupNodeIdPath(groupId), m_modelNs); + + private NodeId ResourceNodeId(string groupId, string resourceId) + => new NodeId(ResourceNodeIdPath(groupId, resourceId), m_modelNs); + + private void WireMethod( + BaseObjectState parent, string browseName, GenericMethodCalledEventHandler2Async handler) + { + MethodState? method = + parent.FindChild(m_manager.SystemContext, new QualifiedName(browseName, XRegistryNs)) + as MethodState + ?? parent.FindChild(m_manager.SystemContext, new QualifiedName(browseName, m_modelNs)) + as MethodState; + if (method is not null) + { + method.OnCallMethod2Async = handler; + } + } + + private ushort XRegistryNs + => (ushort)m_manager.Server.NamespaceUris.GetIndex(XRegistry.Namespaces.XRegistry); + + /// + /// Links the / + /// Properties of every Method in + /// the subtree from their materialized child nodes. The generated + /// instance factories add the argument nodes as plain children without + /// setting these Properties, which the server's Call argument validation + /// requires. + /// + internal static void LinkMethodArguments(NodeState? node, ISystemContext context) + { + if (node is null) + { + return; + } + if (node is MethodState method) + { + var arguments = new List(); + method.GetChildren(context, arguments); + foreach (BaseInstanceState child in arguments) + { + if (child is not PropertyState> args) + { + continue; + } + if (method.InputArguments is null && + string.Equals(args.BrowseName.Name, Ua.BrowseNames.InputArguments, + StringComparison.Ordinal)) + { + method.InputArguments = args; + } + else if (method.OutputArguments is null && + string.Equals(args.BrowseName.Name, Ua.BrowseNames.OutputArguments, + StringComparison.Ordinal)) + { + method.OutputArguments = args; + } + } + } + var children = new List(); + node.GetChildren(context, children); + foreach (BaseInstanceState child in children) + { + LinkMethodArguments(child, context); + } + } + + private static void SetValue(PropertyState? property, T value) + { + if (property is not null) + { + property.Value = value; + } + } + + private static string? GetString(ArrayOf input, int index) + => index < input.Count && input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) is string s + ? s : null; + + private static bool GetBool(ArrayOf input, int index, bool fallback) + => GetBoolOrNull(input, index) ?? fallback; + + private static bool? GetBoolOrNull(ArrayOf input, int index) + => index < input.Count && input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) is bool b + ? b : null; + + private static long? OptionalEpoch(ArrayOf input, int index) + { + if (index >= input.Count) + { + return null; + } + return input[index].AsBoxedObject(Variant.BoxingBehavior.Legacy) switch + { + uint u => u == 0 ? null : u, + int i => i == 0 ? null : i, + long l => l == 0 ? null : l, + _ => null + }; + } + + private static ServiceResult ToServiceResult(WotRegistryMutationResult result) + { + return result.Outcome switch + { + WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning or WoTOutcomeEnum.Unchanged + => ServiceResult.Good, + WoTOutcomeEnum.Rejected + => ServiceResult.Create(StatusCodes.BadInvalidState, result.Message), + _ => ServiceResult.Create(StatusCodes.BadNodeIdUnknown, result.Message) + }; + } + + private sealed class GroupEntry + { + public GroupEntry(GroupState node, WoTDocumentKindEnum kind) + { + Node = node; + Kind = kind; + } + + public GroupState Node { get; } + public WoTDocumentKindEnum Kind { get; } + public Dictionary Resources { get; } + = new(StringComparer.Ordinal); + } + + private sealed class ResourceEntry + { + public ResourceEntry( + WoTDocumentState node, WotResourceFileManager? file, + string groupId, string resourceId, WoTDocumentKindEnum kind) + { + Node = node; + File = file; + GroupId = groupId; + ResourceId = resourceId; + Kind = kind; + } + + public WoTDocumentState Node { get; } + public WotResourceFileManager? File { get; } + public string GroupId { get; } + public string ResourceId { get; } + public WoTDocumentKindEnum Kind { get; } + } + + private readonly WotRegistryNodeManager m_manager; + private readonly IWotRegistryService m_registry; + private readonly WotRegistryServerOptions m_options; + private readonly ILogger m_logger; + private readonly ushort m_modelNs; + private readonly SemaphoreSlim m_gate = new(1, 1); + private readonly Dictionary m_groups = new(StringComparer.Ordinal); + private readonly System.Collections.Concurrent.ConcurrentDictionary + m_resourcesByXid = new(StringComparer.Ordinal); + private BaseObjectState? m_registryNode; + private bool m_disposed; + } +} diff --git a/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs b/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs new file mode 100644 index 0000000000..8909c1cffc --- /dev/null +++ b/src/Opc.Ua.WotCon.Server/WotRegistryServerOptions.cs @@ -0,0 +1,93 @@ +/* ======================================================================== + * 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.Collections.Generic; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Server +{ + /// + /// Options for the WoT Connectivity 1.1 registry NodeManager. These are + /// bindable from configuration (only the simple-typed members) and augmented + /// at runtime with the persistence store, binder registry and management + /// access policy. + /// + public sealed class WotRegistryServerOptions + { + /// + /// Gets or sets the folder used by the file-backed registry store. When + /// null the registry is kept in memory only. + /// + public string? StorageFolder { get; set; } + + /// + /// Gets or sets whether the registry automatically re-projects after every + /// content mutation. Defaults to true. + /// + public bool AutoRefresh { get; set; } = true; + + /// + /// Gets or sets whether unsupported binding forms fail a strict closure + /// (rather than materializing degraded nodes). + /// + public bool StrictBindings { get; set; } + + /// + /// Gets or sets how a superseded projection generation is retired after + /// a successful version switch. + /// + public WotProjectionRetirementPolicy RetirementPolicy { get; set; } = + WotProjectionRetirementPolicy.Graceful; + + /// + /// Gets or sets the id of the group into which legacy 1.02 assets are + /// registered as Thing Description resources. + /// + public string LegacyGroupId { get; set; } = WotRegistryGroups.ThingDescriptions; + + /// Gets the resource bounds enforced by the registry service. + public WotRegistryPersistenceBounds Bounds { get; } = new WotRegistryPersistenceBounds(); + + /// + /// Gets or sets the management access policy used to secure the registry + /// management Methods. + /// + public WotManagementAccessPolicy ManagementAccess { get; set; } + = new WotManagementAccessPolicy(); + + /// + /// Gets the WoT binding capabilities advertised by the registry + /// SupportedBindings object. Empty in this phase (no concrete + /// protocol binders are registered). + /// + public IList SupportedBindings { get; } + = new List(); + } +} diff --git a/src/Opc.Ua.WotCon/Design/Opc.Ua.WoTCon.NodeSet2.csv b/src/Opc.Ua.WotCon/Design/Opc.Ua.WoTCon.NodeSet2.csv new file mode 100644 index 0000000000..81a748ed61 --- /dev/null +++ b/src/Opc.Ua.WotCon/Design/Opc.Ua.WoTCon.NodeSet2.csv @@ -0,0 +1,307 @@ +WoTAssetConnectionManagementType,1,ObjectType +WoTAssetConnectionManagementType_WoTAssetName_Placeholder,2,Object +WotConNamespaceMetadata_NamespaceFile,3,Object +WotConNamespaceMetadata_NamespaceFile_Size,4,Variable +WotConNamespaceMetadata_NamespaceFile_Writable,5,Variable +WotConNamespaceMetadata_NamespaceFile_UserWritable,6,Variable +WotConNamespaceMetadata_NamespaceFile_OpenCount,7,Variable +WotConNamespaceMetadata_NamespaceFile_MimeType,8,Variable +WotConNamespaceMetadata_NamespaceFile_MaxByteStringLength,9,Variable +WotConNamespaceMetadata_NamespaceFile_LastModifiedTime,10,Variable +WotConNamespaceMetadata_NamespaceFile_Open,11,Method +WotConNamespaceMetadata_NamespaceFile_Open_InputArguments,12,Variable +WotConNamespaceMetadata_NamespaceFile_Open_OutputArguments,13,Variable +WotConNamespaceMetadata_NamespaceFile_Close,14,Method +WotConNamespaceMetadata_NamespaceFile_Close_InputArguments,15,Variable +WotConNamespaceMetadata_NamespaceFile_Read,16,Method +WotConNamespaceMetadata_NamespaceFile_Read_InputArguments,17,Variable +WotConNamespaceMetadata_NamespaceFile_Read_OutputArguments,18,Variable +WotConNamespaceMetadata_NamespaceFile_Write,19,Method +WotConNamespaceMetadata_NamespaceFile_Write_InputArguments,20,Variable +WotConNamespaceMetadata_NamespaceFile_GetPosition,21,Method +WotConNamespaceMetadata_NamespaceFile_GetPosition_InputArguments,22,Variable +WotConNamespaceMetadata_NamespaceFile_GetPosition_OutputArguments,23,Variable +WotConNamespaceMetadata_NamespaceFile_SetPosition,24,Method +WotConNamespaceMetadata_NamespaceFile_SetPosition_InputArguments,25,Variable +WoTAssetConnectionManagementType_CreateAsset,26,Method +WoTAssetConnectionManagementType_CreateAsset_InputArguments,27,Variable +WoTAssetConnectionManagementType_CreateAsset_OutputArguments,28,Variable +WoTAssetConnectionManagementType_DeleteAsset,29,Method +WoTAssetConnectionManagementType_DeleteAsset_InputArguments,30,Variable +WoTAssetConnectionManagement,31,Object +WoTAssetConnectionManagement_CreateAsset,32,Method +WoTAssetConnectionManagement_CreateAsset_InputArguments,33,Variable +WoTAssetConnectionManagement_CreateAsset_OutputArguments,34,Variable +WoTAssetConnectionManagement_DeleteAsset,35,Method +WoTAssetConnectionManagement_DeleteAsset_InputArguments,36,Variable +WotConNamespaceMetadata_NamespaceFile_ExportNamespace,37,Method +WotConNamespaceMetadata_ConfigurationVersion,38,Variable +WotConNamespaceMetadata_ModelVersion,39,Variable +WoTAssetConnectionManagementType_SupportedWoTBindings,40,Variable +WoTAssetConnectionManagementType_DiscoverAssets,41,Method +IWoTAssetType,42,ObjectType +IWoTAssetType_WoTFile,43,Object +IWoTAssetType_WoTFile_Size,44,Variable +IWoTAssetType_WoTFile_Writable,45,Variable +IWoTAssetType_WoTFile_UserWritable,46,Variable +IWoTAssetType_WoTFile_OpenCount,47,Variable +WoTAssetConnectionManagementType_DiscoverAssets_OutputArguments,48,Variable +WoTAssetConnectionManagementType_CreateAssetForEndpoint,49,Method +WoTAssetConnectionManagementType_CreateAssetForEndpoint_InputArguments,50,Variable +IWoTAssetType_WoTFile_Open,51,Method +IWoTAssetType_WoTFile_Open_InputArguments,52,Variable +IWoTAssetType_WoTFile_Open_OutputArguments,53,Variable +IWoTAssetType_WoTFile_Close,54,Method +IWoTAssetType_WoTFile_Close_InputArguments,55,Variable +IWoTAssetType_WoTFile_Read,56,Method +IWoTAssetType_WoTFile_Read_InputArguments,57,Variable +IWoTAssetType_WoTFile_Read_OutputArguments,58,Variable +IWoTAssetType_WoTFile_Write,59,Method +IWoTAssetType_WoTFile_Write_InputArguments,60,Variable +IWoTAssetType_WoTFile_GetPosition,61,Method +IWoTAssetType_WoTFile_GetPosition_InputArguments,62,Variable +IWoTAssetType_WoTFile_GetPosition_OutputArguments,63,Variable +IWoTAssetType_WoTFile_SetPosition,64,Method +IWoTAssetType_WoTFile_SetPosition_InputArguments,65,Variable +IWoTAssetType_WoTPropertyName_Placeholder,66,Variable +WotConNamespaceMetadata,67,Object +WotConNamespaceMetadata_NamespaceUri,68,Variable +WotConNamespaceMetadata_NamespaceVersion,69,Variable +WotConNamespaceMetadata_NamespacePublicationDate,70,Variable +WotConNamespaceMetadata_IsNamespaceSubset,71,Variable +WotConNamespaceMetadata_StaticNodeIdTypes,72,Variable +WotConNamespaceMetadata_StaticNumericNodeIdRange,73,Variable +WotConNamespaceMetadata_StaticStringNodeIdPattern,74,Variable +WoTAssetConnectionManagementType_ConnectionTest,75,Method +WoTAssetConnectionManagementType_ConnectionTest_InputArguments,76,Variable +WoTAssetConnectionManagementType_ConnectionTest_OutputArguments,77,Variable +WoTAssetConnectionManagementType_Configuration,78,Object +WoTAssetConnectionManagementType_Configuration_License,79,Variable +WoTAssetConnectionManagement_SupportedWoTBindings,80,Variable +WoTAssetConnectionManagement_DiscoverAssets,81,Method +WoTAssetConnectionManagement_DiscoverAssets_OutputArguments,82,Variable +WoTAssetConnectionManagement_CreateAssetForEndpoint,83,Method +WoTAssetConnectionManagement_CreateAssetForEndpoint_InputArguments,84,Variable +WoTAssetConnectionManagement_ConnectionTest,85,Method +WoTAssetConnectionManagement_ConnectionTest_InputArguments,86,Variable +WoTAssetConnectionManagement_ConnectionTest_OutputArguments,87,Variable +WoTAssetConnectionManagement_Configuration,88,Object +WoTAssetConnectionManagement_Configuration_License,89,Variable +CreateAssetMethodType,90,Method +CreateAssetMethodType_InputArguments,91,Variable +CreateAssetMethodType_OutputArguments,92,Variable +DeleteAssetMethodType,93,Method +DeleteAssetMethodType_InputArguments,94,Variable +DiscoverAssetsMethodType,95,Method +DiscoverAssetsMethodType_OutputArguments,96,Variable +CreateAssetForEndpointMethodType,97,Method +CreateAssetForEndpointMethodType_InputArguments,98,Variable +WotConNamespaceMetadata_DefaultRolePermissions,99,Variable +WotConNamespaceMetadata_DefaultUserRolePermissions,100,Variable +WotConNamespaceMetadata_DefaultAccessRestrictions,101,Variable +ConnectionTestMethodType,102,Method +ConnectionTestMethodType_InputArguments,103,Variable +ConnectionTestMethodType_OutputArguments,104,Variable +WoTAssetConfigurationType,105,ObjectType +IWoTAssetType_WoTFile_CloseAndUpdate,106,Method +IWoTAssetType_WoTFile_CloseAndUpdate_InputArguments,107,Variable +WoTAssetConfigurationType_WoTConfigurationParameterName_Placeholder,108,Variable +WoTAssetConfigurationType_License,109,Variable +WoTAssetFileType,110,ObjectType +WoTAssetFileType_CloseAndUpdate,111,Method +WoTAssetFileType_CloseAndUpdate_InputArguments,112,Variable +IWoTAssetType_WoTFile_MimeType,113,Variable +IWoTAssetType_WoTFile_MaxByteStringLength,114,Variable +WoTAssetType,115,Unspecified +WoTAssetType_WoTFile,116,Unspecified +WoTAssetType_WoTFile_Size,117,Unspecified +WoTAssetType_WoTFile_Writable,118,Unspecified +WoTAssetType_WoTFile_UserWritable,119,Unspecified +WoTAssetType_WoTFile_OpenCount,120,Unspecified +IWoTAssetType_WoTFile_LastModifiedTime,121,Variable +IWoTAssetType_AssetEndpoint,122,Variable +CloseAndUpdateMethodType,123,Method +WoTAssetType_WoTFile_Open,124,Unspecified +WoTAssetType_WoTFile_Open_InputArguments,125,Unspecified +WoTAssetType_WoTFile_Open_OutputArguments,126,Unspecified +WoTAssetType_WoTFile_Close,127,Unspecified +WoTAssetType_WoTFile_Close_InputArguments,128,Unspecified +WoTAssetType_WoTFile_Read,129,Unspecified +WoTAssetType_WoTFile_Read_InputArguments,130,Unspecified +WoTAssetType_WoTFile_Read_OutputArguments,131,Unspecified +WoTAssetType_WoTFile_Write,132,Unspecified +WoTAssetType_WoTFile_Write_InputArguments,133,Unspecified +WoTAssetType_WoTFile_GetPosition,134,Unspecified +WoTAssetType_WoTFile_GetPosition_InputArguments,135,Unspecified +WoTAssetType_WoTFile_GetPosition_OutputArguments,136,Unspecified +WoTAssetType_WoTFile_SetPosition,137,Unspecified +WoTAssetType_WoTFile_SetPosition_InputArguments,138,Unspecified +WoTAssetType_WoTFile_CloseAndUpdate,139,Unspecified +WoTAssetType_WoTFile_CloseAndUpdate_InputArguments,140,Unspecified +WoTAssetType_WoTPropertyName_Placeholder,141,Unspecified +HasWoTComponent,142,ReferenceType +CloseAndUpdateMethodType_InputArguments,143,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile,144,Object +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Size,145,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Writable,146,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_UserWritable,147,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_OpenCount,148,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_MimeType,149,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_MaxByteStringLength,150,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_LastModifiedTime,151,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Open,152,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Open_InputArguments,153,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Open_OutputArguments,154,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Close,155,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Close_InputArguments,156,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Read,157,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Read_InputArguments,158,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Read_OutputArguments,159,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Write,160,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_Write_InputArguments,161,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_GetPosition,162,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_GetPosition_InputArguments,163,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_GetPosition_OutputArguments,164,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_SetPosition,165,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_SetPosition_InputArguments,166,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_CloseAndUpdate,167,Method +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_WoTFile_CloseAndUpdate_InputArguments,168,Variable +WoTAssetConnectionManagementType_WoTAssetName_Placeholder_AssetEndpoint,169,Variable +WoTAssetConnectionManagementType_CreateAssetForEndpoint_OutputArguments,170,Variable +WoTAssetConnectionManagement_CreateAssetForEndpoint_OutputArguments,171,Variable +CreateAssetForEndpointMethodType_OutputArguments,172,Variable +WoTDocumentKindEnum,64020,DataType +WoTDocumentKindEnum_EnumStrings,64500,Variable +WoTLoadStateEnum,64021,DataType +WoTLoadStateEnum_EnumStrings,64501,Variable +WoTRefreshModeEnum,64022,DataType +WoTRefreshModeEnum_EnumStrings,64502,Variable +WoTAtomicityEnum,64023,DataType +WoTAtomicityEnum_EnumStrings,64503,Variable +WoTDeletePolicyEnum,64024,DataType +WoTDeletePolicyEnum_EnumStrings,64504,Variable +WoTOutcomeEnum,64025,DataType +WoTOutcomeEnum_EnumStrings,64505,Variable +WoTPhaseEnum,64026,DataType +WoTPhaseEnum_EnumStrings,64506,Variable +WoTBindingCapabilityEnum,64027,DataType +WoTBindingCapabilityEnum_EnumStrings,64507,Variable +WoTValidationOutcomeDataType,64040,DataType +WoTValidationOutcomeDataType_DefaultBinary,64508,Object +WoTValidationOutcomeDataType_DefaultJSON,64509,Object +WoTBindingCapabilityDataType,64041,DataType +WoTBindingCapabilityDataType_DefaultBinary,64510,Object +WoTBindingCapabilityDataType_DefaultJSON,64511,Object +WoTRefreshOptionsDataType,64042,DataType +WoTRefreshOptionsDataType_DefaultBinary,64512,Object +WoTRefreshOptionsDataType_DefaultJSON,64513,Object +WoTResourceSelectorDataType,64043,DataType +WoTResourceSelectorDataType_DefaultBinary,64514,Object +WoTResourceSelectorDataType_DefaultJSON,64515,Object +WoTResourceLoadResultDataType,64044,DataType +WoTResourceLoadResultDataType_DefaultBinary,64516,Object +WoTResourceLoadResultDataType_DefaultJSON,64517,Object +WoTRefreshSummaryDataType,64045,DataType +WoTRefreshSummaryDataType_DefaultBinary,64518,Object +WoTRefreshSummaryDataType_DefaultJSON,64519,Object +WoTDependencyDataType,64046,DataType +WoTDependencyDataType_DefaultBinary,64520,Object +WoTDependencyDataType_DefaultJSON,64521,Object +WoTRegistryType,64000,ObjectType +ThingDescriptionGroupType,64001,ObjectType +ThingModelGroupType,64002,ObjectType +WoTDocumentType,64003,ObjectType +ThingDescriptionFileType,64004,ObjectType +ThingModelFileType,64005,ObjectType +WoTBindingType,64006,ObjectType +WoTResourceEventType,64010,ObjectType +WoTValidationFailureEventType,64011,ObjectType +WoTLoadFailureEventType,64012,ObjectType +WoTBindingFailureEventType,64013,ObjectType +WoTRefreshCompletedEventType,64014,ObjectType +HasWoTProjection,64060,ReferenceType +WoTRegistryType_AutoRefresh,64522,Variable +WoTRegistryType_RefreshMode,64523,Variable +WoTRegistryType_RefreshInterval,64524,Variable +WoTRegistryType_RefreshGeneration,64525,Variable +WoTRegistryType_LastRefreshTime,64526,Variable +WoTRegistryType_LastRefreshSummary,64527,Variable +WoTRegistryType_DefaultAtomicity,64528,Variable +WoTRegistryType_DeletePolicy,64529,Variable +WoTRegistryType_ValidateFormat,64530,Variable +WoTRegistryType_ValidateCompatibility,64531,Variable +WoTRegistryType_StrictValidation,64532,Variable +WoTRegistryType_VocabularyVersion,64533,Variable +WoTRegistryType_SelectedBindings,64534,Variable +WoTRegistryType_SupportedBindings,64535,Object +WoTRegistryType_ThingDescriptionGroup,64536,Object +WoTRegistryType_ThingModelGroup,64537,Object +WoTRegistryType_Refresh,64538,Method +WoTRegistryType_Refresh_InputArguments,64539,Variable +WoTRegistryType_Refresh_OutputArguments,64540,Variable +ThingDescriptionGroupType_ValidateFormat,64541,Variable +ThingDescriptionGroupType_ValidateCompatibility,64542,Variable +ThingDescriptionGroupType_ConsistentFormat,64543,Variable +ThingDescriptionGroupType_ThingDescription,64544,Object +ThingModelGroupType_ValidateFormat,64545,Variable +ThingModelGroupType_ValidateCompatibility,64546,Variable +ThingModelGroupType_ConsistentFormat,64547,Variable +ThingModelGroupType_ThingModel,64548,Object +WoTDocumentType_DocumentKind,64549,Variable +WoTDocumentType_Enabled,64550,Variable +WoTDocumentType_LoadState,64551,Variable +WoTDocumentType_DesiredVersionId,64552,Variable +WoTDocumentType_ActiveVersionId,64553,Variable +WoTDocumentType_IsDefault,64554,Variable +WoTDocumentType_Ancestor,64555,Variable +WoTDocumentType_Compatibility,64556,Variable +WoTDocumentType_AutoRefresh,64557,Variable +WoTDocumentType_RefreshGeneration,64558,Variable +WoTDocumentType_LastRefreshTime,64559,Variable +WoTDocumentType_ContentDigest,64560,Variable +WoTDocumentType_ValidationOutcome,64561,Variable +WoTDocumentType_MaterializedNodeCount,64562,Variable +WoTDocumentType_RootNodeId,64563,Variable +WoTDocumentType_SelectedBindings,64564,Variable +WoTDocumentType_Validate,64565,Method +WoTDocumentType_Validate_OutputArguments,64566,Variable +WoTDocumentType_SetEnabled,64567,Method +WoTDocumentType_SetEnabled_InputArguments,64568,Variable +WoTDocumentType_SetDefaultVersion,64569,Method +WoTDocumentType_SetDefaultVersion_InputArguments,64570,Variable +ThingDescriptionFileType_ThingId,64571,Variable +ThingDescriptionFileType_ThingTitle,64572,Variable +ThingDescriptionFileType_BaseUri,64573,Variable +ThingDescriptionFileType_ModelReference,64574,Variable +ThingModelFileType_ModelTitle,64575,Variable +ThingModelFileType_ModelVersion,64576,Variable +ThingModelFileType_DerivedTypeNodeId,64577,Variable +WoTBindingType_BindingUri,64578,Variable +WoTBindingType_Title,64579,Variable +WoTBindingType_ProfileVersion,64580,Variable +WoTBindingType_DraftMaturity,64581,Variable +WoTBindingType_Enabled,64582,Variable +WoTBindingType_ContentTypes,64583,Variable +WoTBindingType_Capabilities,64584,Variable +WoTResourceEventType_Xid,64585,Variable +WoTResourceEventType_ResourceId,64586,Variable +WoTResourceEventType_VersionId,64587,Variable +WoTResourceEventType_DocumentKind,64588,Variable +WoTResourceEventType_Generation,64589,Variable +WoTResourceEventType_Phase,64590,Variable +WoTResourceEventType_Outcome,64591,Variable +WoTValidationFailureEventType_ValidationOutcome,64592,Variable +WoTLoadFailureEventType_LoadState,64593,Variable +WoTLoadFailureEventType_FailedNodeId,64594,Variable +WoTLoadFailureEventType_Reason,64595,Variable +WoTBindingFailureEventType_BindingUri,64596,Variable +WoTBindingFailureEventType_Reason,64597,Variable +WoTRefreshCompletedEventType_Summary,64598,Variable +WoTRefreshCompletedEventType_RequestId,64599,Variable +WoTRefreshCompletedEventType_Generation,64600,Variable +WoTRegistry,64100,Object +WoTRegistry_Refresh,64601,Method +WoTRegistry_Refresh_InputArguments,64602,Variable +WoTRegistry_Refresh_OutputArguments,64603,Variable +WoTRegistry_RegistryId,64604,Variable +WoTRegistry_RefreshGeneration,64605,Variable diff --git a/src/Opc.Ua.WotCon/Design/Opc.Ua.WoTCon.NodeSet2.xml b/src/Opc.Ua.WotCon/Design/Opc.Ua.WoTCon.NodeSet2.xml new file mode 100644 index 0000000000..f2552d5bd0 --- /dev/null +++ b/src/Opc.Ua.WotCon/Design/Opc.Ua.WoTCon.NodeSet2.xml @@ -0,0 +1,2746 @@ + + + + + http://opcfoundation.org/UA/xRegistry/ + http://opcfoundation.org/UA/WoT-Con/ + + + + + + + + + i=1 + i=3 + i=5 + i=6 + i=7 + i=9 + i=11 + i=12 + i=13 + i=294 + i=15 + i=17 + i=18 + i=21 + i=290 + i=296 + i=22 + i=29 + i=23751 + i=24 + i=35 + i=37 + i=40 + i=45 + i=46 + i=47 + i=38 + i=17603 + i=41 + i=48 + i=32 + + + WoTDocumentKindEnum + The kind of WoT document a resource carries: a Thing Description (a concrete instance) or a Thing Model (a reusable type template). + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64500 + + A W3C WoT Thing Description (WoT-TD/1.1); projects to OPC UA instances.A W3C WoT Thing Model (WoT-TM/1.1); projects to OPC UA types. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64020 + + ThingDescriptionThingModel + + + WoTLoadStateEnum + The lifecycle state of a WoT document's derived projection in the AddressSpace. The registry file always remains stored; this enum reflects only the state of the code-behind projection. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64501 + + Stored but not projected into the AddressSpace.Format and compatibility validation is in progress.The projection is being materialized under a shadow generation.The projection is committed and serving as the active generation.Validation or projection failed; the last valid projection (if any) stays active.A newer generation has replaced this one; awaiting the configured retirement policy.Graceful retirement is waiting for monitored items and requests to drain.The projection has been removed from the AddressSpace. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64021 + + UnloadedValidatingLoadingActiveFailedSupersededRetiringRetired + + + WoTRefreshModeEnum + How a registry or document triggers refresh of its derived projection. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64502 + + Only an explicit Refresh Method call re-projects.The registry re-projects on a fixed interval (RefreshInterval).The registry re-projects when a stored document changes (write/CloseAndUpdate).The registry re-projects on an implementation-defined schedule. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64022 + + ManualPeriodicEventDrivenScheduled + + + WoTAtomicityEnum + The commit granularity applied when a refresh projects one or more documents. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64503 + + Each resource commits independently; a failure isolates to that resource.All resources of a group commit together or not at all.A document and its full dependency closure (DAG) commit atomically.All selected documents commit as a single all-or-nothing transaction. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64023 + + PerResourcePerGroupPerClosurePerRegistry + + + WoTDeletePolicyEnum + How the registry treats dependents when a document version is unloaded or deleted. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64504 + + Reject the operation while any other loaded document still depends on it.Retire the projection but keep the stored document for dependents to resolve.Unload dependents that resolve only through this document.Force-unload the projection even while dependents remain, marking them Failed. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64024 + + RejectRetireCascadeForce + + + WoTOutcomeEnum + The outcome of a validation, projection or refresh operation on a document or the registry. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64505 + + The operation completed and changed the projection.The operation was idempotent; the content digest matched and nothing changed.The operation completed with non-fatal warnings.The operation was not applicable and was skipped.The operation was rejected by policy (for example concurrency or delete policy).The operation failed; the previous valid projection (if any) remains active. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64025 + + SuccessUnchangedWarningSkippedRejectedFailed + + + WoTPhaseEnum + The processing phase a document reached, used to locate where an outcome was produced. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64506 + + Reading document bytes and resolving registry-scoped context/schema references.Parsing the JSON-LD document.Validating the document against its WoT-TD/WoT-TM format.Validating the version against the resource compatibility policy.Resolving the dependency closure (tm:extends, tm:ref, links rel=type).Materializing types/instances into a shadow generation.Committing the shadow generation as active.Applying the configured graceful or immediate retirement policy. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64026 + + FetchParseFormatValidationCompatibilityValidationDependencyResolutionProjectionActivationRetirement + + + WoTBindingCapabilityEnum + A single interaction operation a protocol binding supports, aligned with the WoT form op vocabulary. + WoT Connectivity 1.1 DataTypes + + i=29 + ns=2;i=64507 + + Read a property affordance.Write a property affordance.Observe (subscribe to) a property affordance.Invoke an action affordance.Subscribe to an event affordance.Unsubscribe from an event affordance. + + + EnumStrings + + i=78 + i=68 + ns=2;i=64027 + + ReadPropertyWritePropertyObservePropertyInvokeActionSubscribeEventUnsubscribeEvent + + + WoTValidationOutcomeDataType + An immutable snapshot of a document's format and compatibility validation result. Read as a single Variant value; a new snapshot is produced on each validation and never mutated in place. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64508 + ns=2;i=64509 + + True if format validation was performed.Outcome of format validation (WoT-TD/WoT-TM conformance).Human-readable reason for the format outcome (empty on success).True if compatibility validation was performed.Outcome of compatibility validation against the resource policy.Human-readable reason for the compatibility outcome (empty on success).The compatibility policy in force (for example NONE, BACKWARD, FULL).UTC time the validation completed.The pinned WoT Binding JSON-LD vocabulary version used for validation. + + + Default Binary + + i=76 + ns=2;i=64040 + + + + Default JSON + + i=76 + ns=2;i=64040 + + + + WoTBindingCapabilityDataType + An immutable snapshot of a protocol binding's identity, version-pinned W3C document, maturity and supported operations. Held as an array element only for immutable snapshots; browseable binding objects (WoTBindingType) carry the live, per-field form. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64510 + ns=2;i=64511 + + The WoT protocol-binding vocabulary URI (for example the OPC UA, HTTP or Modbus binding).Human-readable binding title.The version-pinned W3C binding document version this capability snapshot was built against.The W3C maturity of the pinned binding document (for example WD, CR, PR, REC).The interaction operations this binding supports.The content types this binding produces/consumes. + + + Default Binary + + i=76 + ns=2;i=64041 + + + + Default JSON + + i=76 + ns=2;i=64041 + + + + WoTRefreshOptionsDataType + Immutable options controlling a single Refresh invocation. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64512 + ns=2;i=64513 + + Commit granularity for this refresh.Re-project even when the content digest is unchanged.Validate and compute results without committing any projection change.Also refresh documents that depend on the selected documents.How to treat dependents when a selected document is unloaded/retired.Maximum number of documents projected concurrently; 0 lets the server decide.Overall time budget for the refresh; 0 lets the server decide. + + + Default Binary + + i=76 + ns=2;i=64042 + + + + Default JSON + + i=76 + ns=2;i=64042 + + + + WoTResourceSelectorDataType + An immutable selector identifying which stored documents a Refresh applies to. An empty selector array selects the whole registry. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64514 + ns=2;i=64515 + + Restrict to Thing Descriptions or Thing Models; omit to select both.Restrict to a group by groupid; empty selects all groups.Restrict to a resource by resourceid; empty selects all resources.Restrict to a version by versionid; empty selects the resource's default version.Select a single entity by its xRegistry xid; overrides the other fields when set. + + + Default Binary + + i=76 + ns=2;i=64043 + + + + Default JSON + + i=76 + ns=2;i=64043 + + + + WoTResourceLoadResultDataType + An immutable per-resource result row of a Refresh. Never mutated; the array is a point-in-time snapshot for one generation. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64516 + ns=2;i=64517 + + The xRegistry xid of the affected resource/version.The groupid of the resource's group.The resourceid of the affected resource.The versionid that was projected.Whether the document is a Thing Description or a Thing Model.The per-resource outcome.The phase the resource reached (the failing phase on failure).The resulting load state of the projection.The refresh generation this result belongs to.Number of AddressSpace nodes materialized for this resource.The root node of the materialized projection, if any.The content digest (hash) of the projected document bytes.Human-readable detail for the outcome. + + + Default Binary + + i=76 + ns=2;i=64044 + + + + Default JSON + + i=76 + ns=2;i=64044 + + + + WoTRefreshSummaryDataType + An immutable summary of one Refresh invocation, also carried by the WoTRefreshCompletedEventType and cached on the registry as LastRefreshSummary. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64518 + ns=2;i=64519 + + The caller-supplied request identifier echoed back for correlation.The committed refresh generation (0 on a dry run or full failure).The overall outcome of the refresh.The commit granularity that was applied.UTC start time of the refresh.UTC end time of the refresh.Total number of resources considered.Number of resources that changed successfully.Number of resources that were idempotently unchanged.Number of resources that failed.Number of resources skipped by selection or policy.Number of superseded generations retired. + + + Default Binary + + i=76 + ns=2;i=64045 + + + + Default JSON + + i=76 + ns=2;i=64045 + + + + WoTDependencyDataType + An immutable edge of the document dependency DAG, used to describe closures in results and diagnostics. + WoT Connectivity 1.1 DataTypes + + i=22 + ns=2;i=64520 + ns=2;i=64521 + + The xid of the dependent document.The xid of the document depended upon (empty if unresolved).The raw href/URI of the dependency as authored in the document.The dependency kind (for example tm:extends, tm:ref, links.rel=type).True if the dependency resolved to a stored document. + + + Default Binary + + i=76 + ns=2;i=64046 + + + + Default JSON + + i=76 + ns=2;i=64046 + + + + WoTRegistryType + The WoT Connectivity 1.1 registry root - an xRegistry RegistryType (a FolderType) that holds ThingDescriptionGroupType and ThingModelGroupType groups. The stored Thing Description / Thing Model files and their versions are canonical; the projected AddressSpace (types from Thing Models, instances from Thing Descriptions) is derived code-behind. Exposed as a well-known WoTRegistry object under the Server object (i=2253). Adds registry-wide refresh, generation and validation-policy state and the Refresh Method. + WoT Connectivity 1.1 + + ns=1;i=63000 + ns=2;i=64522 + ns=2;i=64523 + ns=2;i=64524 + ns=2;i=64525 + ns=2;i=64526 + ns=2;i=64527 + ns=2;i=64528 + ns=2;i=64529 + ns=2;i=64530 + ns=2;i=64531 + ns=2;i=64532 + ns=2;i=64533 + ns=2;i=64534 + ns=2;i=64535 + ns=2;i=64536 + ns=2;i=64537 + ns=2;i=64538 + ns=2;i=64014 + + + + ThingDescriptionGroupType + An xRegistry GroupType that collects related ThingDescriptionFileType resources (a Thing Description Group per the WoT xRegistry model). Adds the group-level format/compatibility validation policy. Its <ThingDescription> placeholder constrains members to the Thing Description subtype. + WoT Connectivity 1.1 + + ns=1;i=63001 + ns=2;i=64541 + ns=2;i=64542 + ns=2;i=64543 + ns=2;i=64544 + + + + ThingModelGroupType + An xRegistry GroupType that collects related ThingModelFileType resources (a Thing Model Group per the WoT xRegistry model). Adds the group-level format/compatibility validation policy. Its <ThingModel> placeholder constrains members to the Thing Model subtype. + WoT Connectivity 1.1 + + ns=1;i=63001 + ns=2;i=64545 + ns=2;i=64546 + ns=2;i=64547 + ns=2;i=64548 + + + + WoTDocumentType + The abstract base of a stored WoT document resource - an xRegistry ResourceType (a FileType) whose content bytes are the JSON-LD document, read/written with the inherited Open/Read/Write/Close Methods. Adds the derived-projection metadata (load state, desired/active version, validation and compatibility outcomes, content digest, materialized-node count and root, selected bindings) and the Validate, SetEnabled and SetDefaultVersion Methods. Concrete subtypes fix the document kind. + WoT Connectivity 1.1 + + ns=1;i=63002 + ns=2;i=64549 + ns=2;i=64550 + ns=2;i=64551 + ns=2;i=64552 + ns=2;i=64553 + ns=2;i=64554 + ns=2;i=64555 + ns=2;i=64556 + ns=2;i=64557 + ns=2;i=64558 + ns=2;i=64559 + ns=2;i=64560 + ns=2;i=64561 + ns=2;i=64562 + ns=2;i=64563 + ns=2;i=64564 + ns=2;i=64565 + ns=2;i=64567 + ns=2;i=64569 + ns=2;i=64011 + ns=2;i=64012 + ns=2;i=64013 + + + + ThingDescriptionFileType + A concrete WoTDocumentType whose content is a W3C WoT Thing Description (WoT-TD/1.1, application/td+json). Projects to OPC UA instances: affordances become Variables, Methods and event sources; forms become binder plans. Adds the Thing instance identity (ThingId, base URI) and the link to the Thing Model it derives from. + WoT Connectivity 1.1 + + ns=2;i=64003 + ns=2;i=64571 + ns=2;i=64572 + ns=2;i=64573 + ns=2;i=64574 + + + + ThingModelFileType + A concrete WoTDocumentType whose content is a W3C WoT Thing Model (WoT-TM/1.1, application/tm+json). Projects to OPC UA types: it materializes an ObjectType or VariableType and the affordance member declarations and modelling rules. Adds the derived type NodeId and model version. + WoT Connectivity 1.1 + + ns=2;i=64003 + ns=2;i=64575 + ns=2;i=64576 + ns=2;i=64577 + + + + WoTBindingType + A browseable protocol-binding descriptor: the live, per-field representation of one W3C WoT protocol binding the server can realize (its URI, title, version-pinned W3C document, draft maturity, enabled state, content types and a capability snapshot). Selected/active binding sets are additionally exposed as immutable WoTBindingCapabilityDataType array snapshots. Policy and identity are browseable; no credentials or secrets are ever exposed here. + WoT Connectivity 1.1 + + i=58 + ns=2;i=64578 + ns=2;i=64579 + ns=2;i=64580 + ns=2;i=64581 + ns=2;i=64582 + ns=2;i=64583 + ns=2;i=64584 + + + + WoTResourceEventType + The common base event for a WoT resource lifecycle notification. Carries the identity of the affected resource/version, the document kind, the refresh generation, the phase reached and the outcome. Abstract; servers emit one of its concrete subtypes. + WoT Connectivity 1.1 Events + + i=2041 + ns=2;i=64585 + ns=2;i=64586 + ns=2;i=64587 + ns=2;i=64588 + ns=2;i=64589 + ns=2;i=64590 + ns=2;i=64591 + + + + WoTValidationFailureEventType + Raised when a document fails format or compatibility validation. The failing resource is the event source; the stored document is retained and any previous valid projection stays active. + WoT Connectivity 1.1 Events + + ns=2;i=64010 + ns=2;i=64592 + + + + WoTLoadFailureEventType + Raised when a validated document fails to project (materialize) into the AddressSpace, or when its shadow generation cannot be activated. The failing resource is the event source. + WoT Connectivity 1.1 Events + + ns=2;i=64010 + ns=2;i=64593 + ns=2;i=64594 + ns=2;i=64595 + + + + WoTBindingFailureEventType + Raised when a form cannot be bound to its protocol binding (unknown binding, unsupported operation or a runtime binder error). The failing resource is the event source. + WoT Connectivity 1.1 Events + + ns=2;i=64010 + ns=2;i=64596 + ns=2;i=64597 + + + + WoTRefreshCompletedEventType + Raised by the registry when a Refresh completes (including automatic refreshes). Carries the refresh summary and the committed generation. The registry object is the event source. + WoT Connectivity 1.1 Events + + i=2041 + ns=2;i=64598 + ns=2;i=64599 + ns=2;i=64600 + + + + HasWoTProjection + Links a stored WoT document resource (source) to the root node of its derived AddressSpace projection (target). Used to correlate materialized nodes and their NodeVersion with the canonical document, and to find the document behind a projected node. + WoTProjectionOf + WoT Connectivity 1.1 References + + i=32 + + + + AutoRefresh + True if the registry automatically re-projects stored documents (per RefreshMode); false if only explicit Refresh calls re-project. + + i=80 + i=68 + ns=2;i=64000 + + + + RefreshMode + How automatic refresh is triggered when AutoRefresh is true. + + i=80 + i=68 + ns=2;i=64000 + + + + RefreshInterval + The interval used when RefreshMode is Periodic. + + i=80 + i=68 + ns=2;i=64000 + + + + RefreshGeneration + The current committed projection generation; incremented on every committed refresh. Materialized nodes carry the generation in their NodeVersion for correlation. + + i=78 + i=68 + ns=2;i=64000 + + + + LastRefreshTime + UTC time of the last completed refresh. + + i=80 + i=68 + ns=2;i=64000 + + + + LastRefreshSummary + An immutable snapshot summarizing the last completed refresh. + + i=80 + i=68 + ns=2;i=64000 + + + + DefaultAtomicity + The commit granularity applied when a Refresh omits an explicit atomicity. + + i=80 + i=68 + ns=2;i=64000 + + + + DeletePolicy + The default policy for treating dependents on unload/delete. + + i=80 + i=68 + ns=2;i=64000 + + + + ValidateFormat + Registry-wide default: validate document format on ingest/refresh. + + i=80 + i=68 + ns=2;i=64000 + + + + ValidateCompatibility + Registry-wide default: validate version compatibility on ingest/refresh. + + i=80 + i=68 + ns=2;i=64000 + + + + StrictValidation + If true, a validation warning is treated as a failure. + + i=80 + i=68 + ns=2;i=64000 + + + + VocabularyVersion + The version-pinned WoT Binding JSON-LD vocabulary this registry validates and projects against. + + i=80 + i=68 + ns=2;i=64000 + + + + SelectedBindings + An immutable snapshot array of the protocol bindings currently selected/active registry-wide. + + i=80 + i=68 + ns=2;i=64000 + + + + SupportedBindings + A folder of browseable WoTBindingType binding descriptors the server can realize (the live, per-field form of the selected-bindings snapshot). + + i=80 + i=61 + ns=2;i=64000 + + + + <ThingDescriptionGroup> + A Thing Description Group held by this registry (constrained to the ThingDescriptionGroupType subtype). + + i=11508 + ns=2;i=64001 + ns=2;i=64000 + + + + <ThingModelGroup> + A Thing Model Group held by this registry (constrained to the ThingModelGroupType subtype). + + i=11508 + ns=2;i=64002 + ns=2;i=64000 + + + + Refresh + Re-project selected stored documents into the AddressSpace. Idempotent: a document whose content digest is unchanged is reported Unchanged and not re-materialized unless Options.Force is set. Projects into a shadow generation and switches atomically per Options.Atomicity; superseded generations use the implementation's documented graceful or immediate retirement policy. If ExpectedGeneration is non-zero and does not equal RefreshGeneration, the call fails with Bad_InvalidState and changes nothing (optimistic concurrency). An empty Selection selects the whole registry. + + i=80 + ns=2;i=64000 + ns=2;i=64539 + ns=2;i=64540 + + + + InputArguments + + i=78 + i=68 + ns=2;i=64538 + + i=297Selectionns=2;i=6404310The documents to refresh; empty selects the whole registry.i=297Optionsns=2;i=64042-1Options controlling atomicity, force, dry-run and dependents.i=297ExpectedGenerationi=7-1Expected current RefreshGeneration for optimistic concurrency; 0 disables the check.i=297RequestIdi=12-1Caller-supplied identifier echoed into the summary and the completion event. + + + OutputArguments + + i=78 + i=68 + ns=2;i=64538 + + i=297Summaryns=2;i=64045-1The refresh summary.i=297Resultsns=2;i=6404410The per-resource results.i=297NewGenerationi=7-1The committed generation (unchanged on dry run or full failure). + + + ValidateFormat + Group-level policy: validate Thing Description format (WoT-TD/1.1) on ingest. + + i=80 + i=68 + ns=2;i=64001 + + + + ValidateCompatibility + Group-level policy: validate version compatibility on ingest. + + i=80 + i=68 + ns=2;i=64001 + + + + ConsistentFormat + Group-level policy: require all versions of a resource to share one format. + + i=80 + i=68 + ns=2;i=64001 + + + + <ThingDescription> + A Thing Description resource held by this group (constrained to the ThingDescriptionFileType subtype). + + i=11508 + ns=2;i=64004 + ns=2;i=64001 + + + + ValidateFormat + Group-level policy: validate Thing Model format (WoT-TM/1.1) on ingest. + + i=80 + i=68 + ns=2;i=64002 + + + + ValidateCompatibility + Group-level policy: validate version compatibility on ingest. + + i=80 + i=68 + ns=2;i=64002 + + + + ConsistentFormat + Group-level policy: require all versions of a resource to share one format. + + i=80 + i=68 + ns=2;i=64002 + + + + <ThingModel> + A Thing Model resource held by this group (constrained to the ThingModelFileType subtype). + + i=11508 + ns=2;i=64005 + ns=2;i=64002 + + + + DocumentKind + Whether this document is a Thing Description or a Thing Model. Fixed by the concrete subtype. + + i=78 + i=68 + ns=2;i=64003 + + + + Enabled + The desired enabled state: true requests that the document be validated and projected; false requests unload. + + i=78 + i=68 + ns=2;i=64003 + + + + LoadState + The actual lifecycle state of this document's derived projection. + + i=78 + i=68 + ns=2;i=64003 + + + + DesiredVersionId + The versionid the operator wants active for this resource (the desired/pinned version). + + i=80 + i=68 + ns=2;i=64003 + + + + ActiveVersionId + The versionid whose projection is currently active. + + i=80 + i=68 + ns=2;i=64003 + + + + IsDefault + xRegistry isdefault: true when this version is the resource's default (sticky) version. + + i=80 + i=68 + ns=2;i=64003 + + + + Ancestor + xRegistry ancestor: the versionid this version derives from (version lineage). + + i=80 + i=68 + ns=2;i=64003 + + + + Compatibility + The compatibility policy all versions of this resource adhere to (for example NONE, BACKWARD, FULL). + + i=80 + i=68 + ns=2;i=64003 + + + + AutoRefresh + Per-document override of the registry AutoRefresh setting. + + i=80 + i=68 + ns=2;i=64003 + + + + RefreshGeneration + The registry generation at which this document was last projected. + + i=80 + i=68 + ns=2;i=64003 + + + + LastRefreshTime + UTC time this document was last projected. + + i=80 + i=68 + ns=2;i=64003 + + + + ContentDigest + The content digest (hash) of the stored document bytes; used to make refresh idempotent. + + i=80 + i=68 + ns=2;i=64003 + + + + ValidationOutcome + An immutable snapshot of this document's format and compatibility validation result. + + i=80 + i=68 + ns=2;i=64003 + + + + MaterializedNodeCount + The number of AddressSpace nodes materialized from this document's active projection. + + i=80 + i=68 + ns=2;i=64003 + + + + RootNodeId + The root node of this document's active projection (the type or instance root). + + i=80 + i=68 + ns=2;i=64003 + + + + SelectedBindings + An immutable snapshot array of the protocol bindings selected for this document's forms. + + i=80 + i=68 + ns=2;i=64003 + + + + Validate + Validate the stored document (format and, when enabled, compatibility) without changing its projection. Returns the outcome snapshot; also refreshes the ValidationOutcome Property. + + i=80 + ns=2;i=64003 + ns=2;i=64566 + + + + OutputArguments + + i=78 + i=68 + ns=2;i=64565 + + i=297Outcomens=2;i=64040-1The validation outcome snapshot. + + + SetEnabled + Set the desired Enabled state of this document. Enabling requests validation and projection; disabling requests unload per the registry DeletePolicy. If ExpectedEpoch is non-zero and does not equal the resource's current Epoch the call fails with Bad_InvalidState and changes nothing. + + i=80 + ns=2;i=64003 + ns=2;i=64568 + + + + InputArguments + + i=78 + i=68 + ns=2;i=64567 + + i=297Enabledi=1-1The desired enabled state.i=297ExpectedEpochi=7-1Expected current Epoch for optimistic concurrency; 0 disables the check. + + + SetDefaultVersion + Make a specific version of this resource its default (sticky) version, so that resolvers selecting the resource without a versionid resolve to it. If ExpectedEpoch is non-zero and does not equal the resource's current Epoch the call fails with Bad_InvalidState and changes nothing. + + i=80 + ns=2;i=64003 + ns=2;i=64570 + + + + InputArguments + + i=78 + i=68 + ns=2;i=64569 + + i=297VersionIdi=12-1The versionid to make default.i=297ExpectedEpochi=7-1Expected current Epoch for optimistic concurrency; 0 disables the check. + + + ThingId + The Thing Description id (a URI/URN identifying the concrete Thing instance). + + i=80 + i=68 + ns=2;i=64004 + + + + ThingTitle + The Thing Description human-readable title. + + i=80 + i=68 + ns=2;i=64004 + + + + BaseUri + The Thing Description base URI used to resolve relative form hrefs. + + i=80 + i=68 + ns=2;i=64004 + + + + ModelReference + The xid or href of the Thing Model this Thing Description derives from (links rel=type), when present. + + i=80 + i=68 + ns=2;i=64004 + + + + ModelTitle + The Thing Model human-readable title. + + i=80 + i=68 + ns=2;i=64005 + + + + ModelVersion + The Thing Model version (WoT version.model), when present. + + i=80 + i=68 + ns=2;i=64005 + + + + DerivedTypeNodeId + The ObjectType or VariableType materialized from this Thing Model. + + i=80 + i=68 + ns=2;i=64005 + + + + BindingUri + The WoT protocol-binding vocabulary URI this descriptor represents. + + i=78 + i=68 + ns=2;i=64006 + + + + Title + Human-readable binding title. + + i=80 + i=68 + ns=2;i=64006 + + + + ProfileVersion + The version-pinned W3C binding document version. + + i=80 + i=68 + ns=2;i=64006 + + + + DraftMaturity + The W3C maturity of the pinned binding document (for example WD, CR, PR, REC). + + i=80 + i=68 + ns=2;i=64006 + + + + Enabled + True if the server currently realizes forms of this binding. + + i=80 + i=68 + ns=2;i=64006 + + + + ContentTypes + The content types this binding produces/consumes. + + i=80 + i=68 + ns=2;i=64006 + + + + Capabilities + An immutable capability snapshot for this binding. + + i=80 + i=68 + ns=2;i=64006 + + + + Xid + The xRegistry xid of the affected resource/version. + + i=78 + i=68 + ns=2;i=64010 + + + + ResourceId + The resourceid of the affected resource. + + i=78 + i=68 + ns=2;i=64010 + + + + VersionId + The versionid of the affected version. + + i=78 + i=68 + ns=2;i=64010 + + + + DocumentKind + Whether the document is a Thing Description or a Thing Model. + + i=78 + i=68 + ns=2;i=64010 + + + + Generation + The refresh generation the notification relates to. + + i=78 + i=68 + ns=2;i=64010 + + + + Phase + The phase reached (the failing phase on a failure event). + + i=78 + i=68 + ns=2;i=64010 + + + + Outcome + The outcome the notification reports. + + i=78 + i=68 + ns=2;i=64010 + + + + ValidationOutcome + The full validation outcome snapshot for the failure. + + i=78 + i=68 + ns=2;i=64011 + + + + LoadState + The load state after the failed projection/activation. + + i=78 + i=68 + ns=2;i=64012 + + + + FailedNodeId + The node whose materialization failed, if identifiable. + + i=78 + i=68 + ns=2;i=64012 + + + + Reason + Human-readable failure reason. + + i=78 + i=68 + ns=2;i=64012 + + + + BindingUri + The binding URI that could not be bound. + + i=78 + i=68 + ns=2;i=64013 + + + + Reason + Human-readable binding failure reason. + + i=78 + i=68 + ns=2;i=64013 + + + + Summary + The refresh summary snapshot. + + i=78 + i=68 + ns=2;i=64014 + + + + RequestId + The caller-supplied request identifier echoed from the Refresh call. + + i=78 + i=68 + ns=2;i=64014 + + + + Generation + The committed generation. + + i=78 + i=68 + ns=2;i=64014 + + + + WoTRegistry + The server-wide WoT Connectivity 1.1 registry, a well-known component of the Server object. Its stored Thing Description / Thing Model files are canonical; the projected AddressSpace is derived. It is the notifier for the WoT resource lifecycle events raised by its groups and resources. + WoT Connectivity 1.1 Instances + + ns=2;i=64000 + i=2253 + i=2253 + ns=2;i=64601 + ns=2;i=64604 + ns=2;i=64605 + + + + Refresh + Re-project selected stored documents into the AddressSpace. The functional Refresh Method on the well-known WoTRegistry object; a server binds the concrete handler. + WoT Connectivity 1.1 Instances + + ns=2;i=64100 + ns=2;i=64602 + ns=2;i=64603 + + + + InputArguments + WoT Connectivity 1.1 Instances + + i=68 + ns=2;i=64601 + + i=297Selectionns=2;i=6404310The documents to refresh; empty selects the whole registry.i=297Optionsns=2;i=64042-1Options controlling atomicity, force, dry-run and dependents.i=297ExpectedGenerationi=7-1Expected current RefreshGeneration for optimistic concurrency; 0 disables the check.i=297RequestIdi=12-1Caller-supplied identifier echoed into the summary and the completion event. + + + OutputArguments + WoT Connectivity 1.1 Instances + + i=68 + ns=2;i=64601 + + i=297Summaryns=2;i=64045-1The refresh summary.i=297Resultsns=2;i=6404410The per-resource results.i=297NewGenerationi=7-1The committed generation (unchanged on dry run or full failure). + + + RegistryId + xRegistry registryid: the stable identifier of this registry (Mandatory, inherited from the xRegistry RegistryType). Default value for the well-known instance; a server MAY override it. + WoT Connectivity 1.1 Instances + + i=68 + ns=2;i=64100 + + WoTRegistry + + + RefreshGeneration + The current committed projection generation; incremented on every committed refresh (Mandatory). Materialized as 0 at load time, before any Refresh has committed. + WoT Connectivity 1.1 Instances + + i=68 + ns=2;i=64100 + + 0 + + + WoTAssetConnectionManagementType + WoT Connectivity 1.02 legacy (deprecated) + + i=58 + ns=2;i=2 + ns=2;i=26 + ns=2;i=29 + ns=2;i=40 + ns=2;i=41 + ns=2;i=49 + ns=2;i=75 + ns=2;i=78 + + + + <WoTAssetName> + WoT Connectivity 1.02 legacy (deprecated) + + i=11508 + ns=2;i=1 + i=58 + ns=2;i=144 + ns=2;i=169 + ns=2;i=42 + + + + NamespaceFile + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=11575 + ns=2;i=4 + ns=2;i=5 + ns=2;i=6 + ns=2;i=7 + ns=2;i=8 + ns=2;i=9 + ns=2;i=10 + ns=2;i=11 + ns=2;i=14 + ns=2;i=16 + ns=2;i=19 + ns=2;i=21 + ns=2;i=24 + ns=2;i=37 + + + + Size + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + Writable + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + UserWritable + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + OpenCount + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + MimeType + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + MaxByteStringLength + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + LastModifiedTime + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + i=68 + + + + Open + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=12 + ns=2;i=13 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=11 + i=68 + + i=297Modei=3-1 + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=11 + i=68 + + i=297FileHandlei=7-1 + + + Close + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=15 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=14 + i=68 + + i=297FileHandlei=7-1 + + + Read + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=17 + ns=2;i=18 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=16 + i=68 + + i=297FileHandlei=7-1i=297Lengthi=6-1 + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=16 + i=68 + + i=297Datai=15-1 + + + Write + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=20 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=19 + i=68 + + i=297FileHandlei=7-1i=297Datai=15-1 + + + GetPosition + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=22 + ns=2;i=23 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=21 + i=68 + + i=297FileHandlei=7-1 + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=21 + i=68 + + i=297Positioni=9-1 + + + SetPosition + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=25 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=24 + i=68 + + i=297FileHandlei=7-1i=297Positioni=9-1 + + + CreateAsset + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=1 + ns=2;i=27 + ns=2;i=28 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=26 + i=68 + + i=297AssetNamei=12-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=26 + i=68 + + i=297AssetIdi=17-1 + + + DeleteAsset + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=1 + ns=2;i=30 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=29 + i=68 + + i=297AssetIdi=17-1 + + + WoTAssetConnectionManagement + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=1 + i=85 + ns=2;i=32 + ns=2;i=35 + ns=2;i=80 + ns=2;i=81 + ns=2;i=83 + ns=2;i=85 + ns=2;i=88 + + + + CreateAsset + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + ns=2;i=33 + ns=2;i=34 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=32 + i=68 + + i=297AssetNamei=12-1 + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=32 + i=68 + + i=297AssetIdi=17-1 + + + DeleteAsset + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + ns=2;i=36 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=35 + i=68 + + i=297AssetIdi=17-1 + + + ExportNamespace + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + + + + ConfigurationVersion + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + ModelVersion + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + 1.1.0 + + + SupportedWoTBindings + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=1 + i=68 + + + + DiscoverAssets + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=1 + ns=2;i=48 + + + + IWoTAssetType + WoT Connectivity 1.02 legacy (deprecated) + + i=17602 + ns=2;i=43 + ns=2;i=66 + ns=2;i=122 + + + + WoTFile + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=42 + ns=2;i=110 + ns=2;i=44 + ns=2;i=45 + ns=2;i=46 + ns=2;i=47 + ns=2;i=51 + ns=2;i=54 + ns=2;i=56 + ns=2;i=59 + ns=2;i=61 + ns=2;i=64 + ns=2;i=106 + ns=2;i=113 + ns=2;i=114 + ns=2;i=121 + + + + Size + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + i=68 + + + + Writable + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + i=68 + + + + UserWritable + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + i=68 + + + + OpenCount + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + i=68 + + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=41 + i=68 + + i=297AssetEndpointsi=1210 + + + CreateAssetForEndpoint + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=1 + ns=2;i=50 + ns=2;i=170 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=49 + i=68 + + i=297AssetNamei=12-1i=297AssetEndpointi=12-1 + + + Open + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=52 + ns=2;i=53 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=51 + i=68 + + i=297Modei=3-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=51 + i=68 + + i=297FileHandlei=7-1 + + + Close + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=55 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=54 + i=68 + + i=297FileHandlei=7-1 + + + Read + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=57 + ns=2;i=58 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=56 + i=68 + + i=297FileHandlei=7-1i=297Lengthi=6-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=56 + i=68 + + i=297Datai=15-1 + + + Write + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=60 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=59 + i=68 + + i=297FileHandlei=7-1i=297Datai=15-1 + + + GetPosition + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=62 + ns=2;i=63 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=61 + i=68 + + i=297FileHandlei=7-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=61 + i=68 + + i=297Positioni=9-1 + + + SetPosition + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=65 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=64 + i=68 + + i=297FileHandlei=7-1i=297Positioni=9-1 + + + <WoTPropertyName> + WoT Connectivity 1.02 legacy (deprecated) + + i=11508 + ns=2;i=42 + i=63 + + + + http://opcfoundation.org/UA/WoT-Con/ + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=3 + ns=2;i=38 + ns=2;i=39 + i=11616 + i=11715 + ns=2;i=68 + ns=2;i=69 + ns=2;i=70 + ns=2;i=71 + ns=2;i=72 + ns=2;i=73 + ns=2;i=74 + ns=2;i=99 + ns=2;i=100 + ns=2;i=101 + + + + NamespaceUri + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + http://opcfoundation.org/UA/WoT-Con/ + + + NamespaceVersion + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + 1.1.0 + + + NamespacePublicationDate + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + 2026-07-22T00:00:00Z + + + IsNamespaceSubset + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + false + + + StaticNodeIdTypes + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + StaticNumericNodeIdRange + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + StaticStringNodeIdPattern + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + + ConnectionTest + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=1 + ns=2;i=76 + ns=2;i=77 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=75 + i=68 + + i=297AssetEndpointi=12-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=75 + i=68 + + i=297Successi=1-1i=297Statusi=12-1 + + + Configuration + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=1 + ns=2;i=105 + ns=2;i=79 + + + + License + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=78 + i=68 + + + + SupportedWoTBindings + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + i=68 + + + + DiscoverAssets + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + ns=2;i=82 + + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=81 + i=68 + + i=297AssetEndpointsi=1210 + + + CreateAssetForEndpoint + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + ns=2;i=84 + ns=2;i=171 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=83 + i=68 + + i=297AssetNamei=12-1i=297AssetEndpointi=12-1 + + + ConnectionTest + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + ns=2;i=86 + ns=2;i=87 + + + + InputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=85 + i=68 + + i=297AssetEndpointi=12-1 + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=85 + i=68 + + i=297Successi=1-1i=297Statusi=12-1 + + + Configuration + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=31 + ns=2;i=105 + ns=2;i=89 + + + + License + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=88 + i=68 + + + + CreateAssetMethodType + WoT Connectivity 1.02 legacy (deprecated) + + ns=2;i=91 + ns=2;i=92 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=90 + i=68 + + i=297AssetNamei=12-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=90 + i=68 + + i=297AssetIdi=17-1 + + + DeleteAssetMethodType + WoT Connectivity 1.02 legacy (deprecated) + + ns=2;i=94 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=93 + i=68 + + i=297AssetIdi=17-1 + + + DiscoverAssetsMethodType + WoT Connectivity 1.02 legacy (deprecated) + + ns=2;i=96 + + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=95 + i=68 + + i=297AssetEndpointsi=1210 + + + CreateAssetForEndpointMethodType + WoT Connectivity 1.02 legacy (deprecated) + + ns=2;i=98 + ns=2;i=172 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=97 + i=68 + + i=297AssetNamei=12-1i=297AssetEndpointi=12-1 + + + DefaultRolePermissions + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + DefaultUserRolePermissions + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + DefaultAccessRestrictions + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=67 + i=68 + + + + ConnectionTestMethodType + WoT Connectivity 1.02 legacy (deprecated) + + ns=2;i=103 + ns=2;i=104 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=102 + i=68 + + i=297AssetEndpointi=12-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=102 + i=68 + + i=297Successi=1-1i=297Statusi=12-1 + + + WoTAssetConfigurationType + WoT Connectivity 1.02 legacy (deprecated) + + i=17602 + ns=2;i=108 + ns=2;i=109 + + + + CloseAndUpdate + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=43 + ns=2;i=107 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=106 + i=68 + + i=297FileHandlei=7-1 + + + <WoTConfigurationParameterName> + WoT Connectivity 1.02 legacy (deprecated) + + i=11508 + ns=2;i=105 + i=68 + + + + License + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=105 + i=68 + + + + WoTAssetFileType + WoT Connectivity 1.02 legacy (deprecated) + + i=11575 + ns=2;i=111 + + + + CloseAndUpdate + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=110 + ns=2;i=112 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=111 + i=68 + + i=297FileHandlei=7-1 + + + MimeType + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=43 + i=68 + + + + MaxByteStringLength + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=43 + i=68 + + + + LastModifiedTime + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=43 + i=68 + + + + AssetEndpoint + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=42 + i=68 + + + + CloseAndUpdateMethodType + WoT Connectivity 1.02 legacy (deprecated) + + ns=2;i=143 + + + + HasWoTComponent + WoTComponentOf + WoT Connectivity 1.02 legacy (deprecated) + + i=47 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=123 + i=68 + + i=297FileHandlei=7-1 + + + WoTFile + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=2 + ns=2;i=110 + ns=2;i=145 + ns=2;i=146 + ns=2;i=147 + ns=2;i=148 + ns=2;i=149 + ns=2;i=150 + ns=2;i=151 + ns=2;i=152 + ns=2;i=155 + ns=2;i=157 + ns=2;i=160 + ns=2;i=162 + ns=2;i=165 + ns=2;i=167 + + + + Size + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + i=68 + + + + Writable + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + i=68 + + + + UserWritable + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + i=68 + + + + OpenCount + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + i=68 + + + + MimeType + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=144 + i=68 + + + + MaxByteStringLength + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=144 + i=68 + + + + LastModifiedTime + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=144 + i=68 + + + + Open + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=153 + ns=2;i=154 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=152 + i=68 + + i=297Modei=3-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=152 + i=68 + + i=297FileHandlei=7-1 + + + Close + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=156 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=155 + i=68 + + i=297FileHandlei=7-1 + + + Read + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=158 + ns=2;i=159 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=157 + i=68 + + i=297FileHandlei=7-1i=297Lengthi=6-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=157 + i=68 + + i=297Datai=15-1 + + + Write + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=161 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=160 + i=68 + + i=297FileHandlei=7-1i=297Datai=15-1 + + + GetPosition + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=163 + ns=2;i=164 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=162 + i=68 + + i=297FileHandlei=7-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=162 + i=68 + + i=297Positioni=9-1 + + + SetPosition + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=166 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=165 + i=68 + + i=297FileHandlei=7-1i=297Positioni=9-1 + + + CloseAndUpdate + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=144 + ns=2;i=168 + + + + InputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=167 + i=68 + + i=297FileHandlei=7-1 + + + AssetEndpoint + WoT Connectivity 1.02 legacy (deprecated) + + i=80 + ns=2;i=2 + i=68 + + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=49 + i=68 + + i=297AssetIdi=17-1 + + + OutputArguments + WoT Connectivity 1.02 Legacy Instances + + ns=2;i=83 + i=68 + + i=297AssetIdi=17-1 + + + OutputArguments + WoT Connectivity 1.02 legacy (deprecated) + + i=78 + ns=2;i=97 + i=68 + + i=297AssetIdi=17-1 + + diff --git a/src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.csv b/src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.csv new file mode 100644 index 0000000000..a20957023c --- /dev/null +++ b/src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.csv @@ -0,0 +1,66 @@ +RegistryType,63000,ObjectType +GroupType,63001,ObjectType +ResourceType,63002,ObjectType +AttributesType,63003,ObjectType +AttributesType_Attribute,63500,Variable +AttributesType_AddAttribute,63501,Method +AttributesType_AddAttribute_InputArguments,63502,Variable +AttributesType_RemoveAttribute,63503,Method +AttributesType_RemoveAttribute_InputArguments,63504,Variable +RegistryCapabilitiesDataType,63004,DataType +RegistryCapabilitiesDataType_DefaultBinary,63505,Object +RegistryCapabilitiesDataType_DefaultJSON,63506,Object +RegistryType_RegistryId,63507,Variable +RegistryType_SpecVersion,63508,Variable +RegistryType_Capabilities,63509,Object +RegistryType_Model,63510,Object +RegistryType_CapabilitiesInfo,63511,Variable +RegistryType_Xid,63512,Variable +RegistryType_Epoch,63513,Variable +RegistryType_Name,63514,Variable +RegistryType_Description,63515,Variable +RegistryType_Documentation,63516,Variable +RegistryType_Labels,63517,Object +RegistryType_CreatedAt,63518,Variable +RegistryType_ModifiedAt,63519,Variable +RegistryType_Group,63520,Object +RegistryType_CreateGroup,63521,Method +RegistryType_CreateGroup_InputArguments,63522,Variable +RegistryType_CreateGroup_OutputArguments,63523,Variable +RegistryType_GetOrCreateGroup,63524,Method +RegistryType_GetOrCreateGroup_InputArguments,63525,Variable +RegistryType_GetOrCreateGroup_OutputArguments,63526,Variable +GroupType_GroupId,63527,Variable +GroupType_Xid,63528,Variable +GroupType_Epoch,63529,Variable +GroupType_Name,63530,Variable +GroupType_Description,63531,Variable +GroupType_Documentation,63532,Variable +GroupType_Labels,63533,Object +GroupType_CreatedAt,63534,Variable +GroupType_ModifiedAt,63535,Variable +GroupType_Resource,63536,Object +GroupType_CreateResource,63537,Method +GroupType_CreateResource_InputArguments,63538,Variable +GroupType_CreateResource_OutputArguments,63539,Variable +GroupType_GetOrCreateResource,63540,Method +GroupType_GetOrCreateResource_InputArguments,63541,Variable +GroupType_GetOrCreateResource_OutputArguments,63542,Variable +GroupType_Delete,63543,Method +GroupType_Delete_InputArguments,63544,Variable +ResourceType_ResourceId,63545,Variable +ResourceType_VersionId,63546,Variable +ResourceType_Format,63547,Variable +ResourceType_ContentType,63548,Variable +ResourceType_ExternalReference,63549,Variable +ResourceType_ResourceUrl,63550,Variable +ResourceType_Xid,63551,Variable +ResourceType_Epoch,63552,Variable +ResourceType_Name,63553,Variable +ResourceType_Description,63554,Variable +ResourceType_Documentation,63555,Variable +ResourceType_Labels,63556,Object +ResourceType_CreatedAt,63557,Variable +ResourceType_ModifiedAt,63558,Variable +ResourceType_Delete,63559,Method +ResourceType_Delete_InputArguments,63560,Variable diff --git a/src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.xml b/src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.xml new file mode 100644 index 0000000000..db6338fb94 --- /dev/null +++ b/src/Opc.Ua.WotCon/Design/Opc.Ua.XRegistry.NodeSet2.xml @@ -0,0 +1,670 @@ + + + + + http://opcfoundation.org/UA/xRegistry/ + + + + + + + + i=1 + i=7 + i=12 + i=13 + i=15 + i=18 + i=290 + i=296 + i=14533 + i=17 + i=35 + i=37 + i=40 + i=45 + i=46 + i=47 + i=38 + + + RegistryType + The abstract xRegistry root, expressed as a FolderType that organizes its Group objects. It creates groups through the CreateGroup Method; a group is removed with its own Delete Method. The physical backing may be a file-system directory, but the type is a plain organizing folder. Domain registries subtype this. + xRegistry + + i=61 + ns=1;i=63507 + ns=1;i=63508 + ns=1;i=63509 + ns=1;i=63510 + ns=1;i=63511 + ns=1;i=63512 + ns=1;i=63513 + ns=1;i=63514 + ns=1;i=63515 + ns=1;i=63516 + ns=1;i=63517 + ns=1;i=63518 + ns=1;i=63519 + ns=1;i=63520 + ns=1;i=63521 + ns=1;i=63524 + + + + GroupType + An abstract xRegistry group, expressed as a FolderType that organizes its resource files. It creates resources and versions through the CreateResource Method and is removed with its own Delete Method. Domain group types subtype this and add the group key (e.g. a namespace URI). + xRegistry + + i=61 + ns=1;i=63527 + ns=1;i=63528 + ns=1;i=63529 + ns=1;i=63530 + ns=1;i=63531 + ns=1;i=63532 + ns=1;i=63533 + ns=1;i=63534 + ns=1;i=63535 + ns=1;i=63536 + ns=1;i=63537 + ns=1;i=63540 + ns=1;i=63543 + + + + ResourceType + An abstract xRegistry resource/version whose document IS the file: the content is read and written through the inherited FileType methods (Open/Read/Write/Close). Carries the xRegistry attributes and an optional ExternalReference for federation. Domain resource types subtype this. + xRegistry + + i=11575 + ns=1;i=63545 + ns=1;i=63546 + ns=1;i=63547 + ns=1;i=63548 + ns=1;i=63549 + ns=1;i=63550 + ns=1;i=63551 + ns=1;i=63552 + ns=1;i=63553 + ns=1;i=63554 + ns=1;i=63555 + ns=1;i=63556 + ns=1;i=63557 + ns=1;i=63558 + ns=1;i=63559 + + + + AttributesType + A container for an entity's extensible xRegistry attributes/labels. Each attribute materializes as a browsable HasProperty PropertyType Variable whose BrowseName is the attribute key, so attributes can be browsed, read and enumerated, and are deleted with the owning entity. The AddAttribute/RemoveAttribute Methods add and remove attributes. This follows the OPC UA extensible-container pattern (an OptionalPlaceholder Property plus Add/Remove Methods); the placeholder isolates dynamic attributes so they never conflict with an entity's fixed attribute BrowseNames. + xRegistry + + i=58 + ns=1;i=63500 + ns=1;i=63501 + ns=1;i=63503 + + + + <Attribute> + An xRegistry attribute or label materialized as a PropertyType Variable: the BrowseName is the attribute key and the Value is its string value. OptionalPlaceholder so a server exposes one Variable per present attribute. + + i=11508 + i=68 + ns=1;i=63003 + + + + AddAttribute + Add or update an xRegistry attribute/label in this container. The server materializes it as a browsable PropertyType Variable whose BrowseName is the Key, and increments the owning entity's Epoch. If ExpectedEpoch is non-zero and does not equal the owning entity's current Epoch, the call fails with Bad_InvalidState and makes no change (optimistic concurrency). Success or failure is conveyed by the Method Call StatusCode. + + i=80 + ns=1;i=63003 + ns=1;i=63502 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63501 + + i=297Keyi=12-1Attribute (or label) name.i=297Valuei=12-1Attribute value.i=297ExpectedEpochi=7-1Expected current Epoch of the owning entity for optimistic concurrency; 0 disables the check. + + + RemoveAttribute + Remove an xRegistry attribute/label (the Variable whose BrowseName is the Key) from this container. If ExpectedEpoch is non-zero and does not equal the owning entity's current Epoch, the call fails with Bad_InvalidState and makes no change. Success or failure is conveyed by the Method Call StatusCode. + + i=80 + ns=1;i=63003 + ns=1;i=63504 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63503 + + i=297Keyi=12-1Attribute (or label) name.i=297ExpectedEpochi=7-1Expected current Epoch of the owning entity for optimistic concurrency; 0 disables the check. + + + RegistryCapabilitiesDataType + The typed form of the xRegistry capabilities document (xRegistry /capabilities), whose fields have a fixed schema in the xRegistry core specification. Read as a single Variant value from RegistryType.CapabilitiesInfo, in addition to the raw JSON exposed by the Capabilities FileType. Additional/vendor capability keys that are not among these fixed fields remain available through the Capabilities FileType JSON. + xRegistry + + i=22 + ns=1;i=63505 + ns=1;i=63506 + + The request flags the registry supports (e.g. doc, epoch, filter, inline, sort).Which parts of the registry are mutable (e.g. capabilities, model, entities).Whether the registry supports pagination of collections.Whether the registry offers a shortself alias for entity self URLs.The xRegistry specification versions the registry supports.Whether the registry keeps the default version sticky across updates.Whether the registry enforces version compatibility on updates.The additional API endpoints the registry offers (e.g. /export).The schema formats the registry can validate against (schema-domain registries). + + + Default Binary + + i=76 + ns=1;i=63004 + + + + Default JSON + + i=76 + ns=1;i=63004 + + + + RegistryId + xRegistry registryid: the stable identifier of this registry. + + i=78 + i=68 + ns=1;i=63000 + + + + SpecVersion + The xRegistry specification version this registry conforms to. + + i=80 + i=68 + ns=1;i=63000 + + + + Capabilities + The registry capabilities document (xRegistry /capabilities): a FileType whose content is the capabilities JSON, read with the inherited Open/Read/Close Methods (so an arbitrarily large document is not bounded by MaxStringLength). + + i=80 + i=11575 + ns=1;i=63000 + + + + Model + The registry model document (xRegistry /model): a FileType whose content is the model JSON, read with the inherited Open/Read/Close Methods. No structured DataType is defined for the model because the OPC UA AddressSpace type system (the ObjectTypes and their members) is the structural equivalent of the model. + + i=80 + i=11575 + ns=1;i=63000 + + + + CapabilitiesInfo + The typed form of the registry capabilities (RegistryCapabilitiesDataType), read as a single Variant value, in addition to the raw JSON of the Capabilities FileType. + + i=80 + i=68 + ns=1;i=63000 + + + + Xid + xRegistry relative identifier (xid): the entity's stable path within the registry, independent of the hosting endpoint. + + i=80 + i=68 + ns=1;i=63000 + + + + Epoch + xRegistry epoch: a counter that increments on every change to the entity. + + i=80 + i=68 + ns=1;i=63000 + + + + Name + Human-readable name of the entity. + + i=80 + i=68 + ns=1;i=63000 + + + + Description + Human-readable description of the entity. + + i=80 + i=68 + ns=1;i=63000 + + + + Documentation + URL to human-readable documentation for the entity. + + i=80 + i=68 + ns=1;i=63000 + + + + Labels + The entity's extensible xRegistry labels/attributes, exposed as an AttributesType container: each label is a browsable PropertyType Variable, added and removed with the container's AddAttribute/RemoveAttribute Methods. Deleted together with the entity. + + i=80 + ns=1;i=63003 + ns=1;i=63000 + + + + CreatedAt + UTC timestamp when the entity was created. + + i=80 + i=68 + ns=1;i=63000 + + + + ModifiedAt + UTC timestamp when the entity was last modified. + + i=80 + i=68 + ns=1;i=63000 + + + + <Group> + A group held by this registry. + + i=11508 + ns=1;i=63001 + ns=1;i=63000 + + + + CreateGroup + Create a group under this registry and assign its GroupId. The server creates the GroupType Object and bootstraps its xRegistry attributes (Xid, Epoch, CreatedAt, ModifiedAt). Fails if a group with the same GroupId already exists; use GetOrCreateGroup for idempotent create-or-get. + + i=80 + ns=1;i=63000 + ns=1;i=63522 + ns=1;i=63523 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63521 + + i=297GroupIdi=12-1The groupid of the group to create. + + + OutputArguments + + i=78 + i=68 + ns=1;i=63521 + + i=297GroupNodeIdi=17-1NodeId of the created group Object. + + + GetOrCreateGroup + Idempotently return the group with this GroupId, creating it if absent. One-shot form that avoids a separate existence check: returns the existing GroupType Object (Created = false) or a newly created and bootstrapped one (Created = true). + + i=80 + ns=1;i=63000 + ns=1;i=63525 + ns=1;i=63526 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63524 + + i=297GroupIdi=12-1The groupid to get or create. + + + OutputArguments + + i=78 + i=68 + ns=1;i=63524 + + i=297GroupNodeIdi=17-1NodeId of the existing or newly created group Object.i=297Createdi=1-1True if the group was created, false if it already existed. + + + GroupId + xRegistry groupid: the stable identifier of this group. Group identifiers are globally unique for federation. + + i=78 + i=68 + ns=1;i=63001 + + + + Xid + xRegistry relative identifier (xid): the entity's stable path within the registry, independent of the hosting endpoint. + + i=80 + i=68 + ns=1;i=63001 + + + + Epoch + xRegistry epoch: a counter that increments on every change to the entity. + + i=80 + i=68 + ns=1;i=63001 + + + + Name + Human-readable name of the entity. + + i=80 + i=68 + ns=1;i=63001 + + + + Description + Human-readable description of the entity. + + i=80 + i=68 + ns=1;i=63001 + + + + Documentation + URL to human-readable documentation for the entity. + + i=80 + i=68 + ns=1;i=63001 + + + + Labels + The entity's extensible xRegistry labels/attributes, exposed as an AttributesType container: each label is a browsable PropertyType Variable, added and removed with the container's AddAttribute/RemoveAttribute Methods. Deleted together with the entity. + + i=80 + ns=1;i=63003 + ns=1;i=63001 + + + + CreatedAt + UTC timestamp when the entity was created. + + i=80 + i=68 + ns=1;i=63001 + + + + ModifiedAt + UTC timestamp when the entity was last modified. + + i=80 + i=68 + ns=1;i=63001 + + + + <Resource> + A resource file held by this group. + + i=11508 + ns=1;i=63002 + ns=1;i=63001 + + + + CreateResource + Create a resource, or a new version of an existing resource, as a ResourceType file in this group, optionally opened for writing. A resource version is identified by (ResourceId, VersionId): when the ResourceId is new the resource is created with this first version; when the ResourceId already exists a new sibling version is created. When VersionId is empty the server assigns the next versionid per the registry model. The server bootstraps the resource's xRegistry attributes when the file is closed. Fails with Bad_NodeIdExists if that exact (ResourceId, VersionId) already exists; use GetOrCreateResource for idempotent create-or-get. + + i=80 + ns=1;i=63001 + ns=1;i=63538 + ns=1;i=63539 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63537 + + i=297ResourceIdi=12-1The resourceid of the resource.i=297VersionIdi=12-1The versionid of the version to create; empty to let the server assign the next versionid per the registry model.i=297RequestFileOpeni=1-1If true, the new resource file is opened for writing and a FileHandle is returned. + + + OutputArguments + + i=78 + i=68 + ns=1;i=63537 + + i=297ResourceNodeIdi=17-1NodeId of the created resource/version Object.i=297VersionIdi=12-1The versionid assigned to the created version.i=297FileHandlei=7-1Write handle when RequestFileOpen is true; otherwise 0. + + + GetOrCreateResource + Idempotently return the (ResourceId, VersionId) version, creating it if absent, optionally opened for writing. When VersionId is empty the resource's default (latest) version is returned, or - if the resource does not yet exist - created as its first version. One-shot form that avoids a separate existence check: returns the existing ResourceType file (Created = false) or a newly created one (Created = true); a write FileHandle is returned when RequestFileOpen is true. + + i=80 + ns=1;i=63001 + ns=1;i=63541 + ns=1;i=63542 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63540 + + i=297ResourceIdi=12-1The resourceid to get or create.i=297VersionIdi=12-1The versionid to get or create; empty selects or creates the default version.i=297RequestFileOpeni=1-1If true, the resource file is opened for writing and a FileHandle is returned. + + + OutputArguments + + i=78 + i=68 + ns=1;i=63540 + + i=297ResourceNodeIdi=17-1NodeId of the existing or newly created resource/version Object.i=297VersionIdi=12-1The versionid of the returned version.i=297FileHandlei=7-1Write handle when RequestFileOpen is true; otherwise 0.i=297Createdi=1-1True if the resource/version was created, false if it already existed. + + + Delete + Delete this group and everything it contains (its resources and their versions and labels). The xRegistry-semantic deletion Method, symmetric with CreateResource. If ExpectedEpoch is non-zero and does not equal the group's current Epoch, the call fails with Bad_InvalidState and deletes nothing; 0 disables the check. Success or failure is conveyed by the Method Call StatusCode. + + i=80 + ns=1;i=63001 + ns=1;i=63544 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63543 + + i=297ExpectedEpochi=7-1Expected current Epoch of the group for optimistic concurrency; 0 disables the check. + + + ResourceId + xRegistry resourceid: the stable identifier of the resource within its group. + + i=78 + i=68 + ns=1;i=63002 + + + + VersionId + xRegistry versionid: the identifier of the version this file represents. + + i=80 + i=68 + ns=1;i=63002 + + + + Format + xRegistry format string identifying the document's schema language/shape. + + i=80 + i=68 + ns=1;i=63002 + + + + ContentType + Media type (content-type) of the document bytes. + + i=80 + i=68 + ns=1;i=63002 + + + + ExternalReference + Federation link: an ExpandedNodeId identifying this resource in another (possibly remote) registry - the ServerUri identifies the hosting registry endpoint, the NamespaceUri and Identifier identify the group and resource. Present when the document is served by reference (xRegistry <RESOURCE>url). + + i=80 + i=68 + ns=1;i=63002 + + + + ResourceUrl + Federation link (string form): the URL from which the document can be obtained (xRegistry <RESOURCE>url), for example an opc.tcp endpoint plus browse path, or an HTTP URL. + + i=80 + i=68 + ns=1;i=63002 + + + + Xid + xRegistry relative identifier (xid): the entity's stable path within the registry, independent of the hosting endpoint. + + i=80 + i=68 + ns=1;i=63002 + + + + Epoch + xRegistry epoch: a counter that increments on every change to the entity. + + i=80 + i=68 + ns=1;i=63002 + + + + Name + Human-readable name of the entity. + + i=80 + i=68 + ns=1;i=63002 + + + + Description + Human-readable description of the entity. + + i=80 + i=68 + ns=1;i=63002 + + + + Documentation + URL to human-readable documentation for the entity. + + i=80 + i=68 + ns=1;i=63002 + + + + Labels + The entity's extensible xRegistry labels/attributes, exposed as an AttributesType container: each label is a browsable PropertyType Variable, added and removed with the container's AddAttribute/RemoveAttribute Methods. Deleted together with the entity. + + i=80 + ns=1;i=63003 + ns=1;i=63002 + + + + CreatedAt + UTC timestamp when the entity was created. + + i=80 + i=68 + ns=1;i=63002 + + + + ModifiedAt + UTC timestamp when the entity was last modified. + + i=80 + i=68 + ns=1;i=63002 + + + + Delete + Delete this resource file and everything it contains (its versions and labels). The xRegistry-semantic deletion Method, symmetric with the group's Delete and consistent with the resource being a FileType. If ExpectedEpoch is non-zero and does not equal the resource's current Epoch, the call fails with Bad_InvalidState and deletes nothing; 0 disables the check. Success or failure is conveyed by the Method Call StatusCode. + + i=80 + ns=1;i=63002 + ns=1;i=63560 + + + + InputArguments + + i=78 + i=68 + ns=1;i=63559 + + i=297ExpectedEpochi=7-1Expected current Epoch of the resource for optimistic concurrency; 0 disables the check. + + diff --git a/src/Opc.Ua.WotCon/Design/Sync-WotConModels.ps1 b/src/Opc.Ua.WotCon/Design/Sync-WotConModels.ps1 new file mode 100644 index 0000000000..2d5c444800 --- /dev/null +++ b/src/Opc.Ua.WotCon/Design/Sync-WotConModels.ps1 @@ -0,0 +1,145 @@ +#Requires -Version 7.0 +<# +.SYNOPSIS + Synchronizes / verifies the pinned xRegistry and WoT Connectivity 1.1 + NodeSet2 model artifacts against the authoring draft repository. + +.DESCRIPTION + The Opc.Ua.WotCon model assembly source-generates two NodeSet2 models + copied ("pinned") from the OPC UA drafts authoring repository: + + core-specs/xregistry/Opc.Ua.XRegistry.NodeSet2.xml -> Design/Opc.Ua.XRegistry.NodeSet2.xml + core-specs/xregistry/Opc.Ua.XRegistry.NodeIds.csv -> Design/Opc.Ua.XRegistry.NodeSet2.csv + wot-specs/WoT-Connectivity/Opc.Ua.WoTCon.NodeSet2.xml -> Design/Opc.Ua.WoTCon.NodeSet2.xml + wot-specs/WoT-Connectivity/Opc.Ua.WoTCon.NodeIds.csv -> Design/Opc.Ua.WoTCon.NodeSet2.csv + + The combined Opc.Ua.WoTCon NodeSet2 is WoT Connectivity revision 1.1: it + incorporates the complete published OPC 10100-1 v1.02 model (NodeIds + 1..172, marked deprecated) plus the additive registry nodes (64000+) in + one namespace, http://opcfoundation.org/UA/WoT-Con/. The legacy 1.02 + ModelDesign sources (WotConnection.xml / WotConnection.csv) are retained + here only as human-readable documentation of the incorporated surface; + they are no longer source-generated (the combined NodeSet is the single + generation input, so the 1.02 model is never generated a second time). + + The generator matches each *.NodeSet2.xml to a side-by-side *.NodeSet2.csv + stable NodeId table, so the draft *.NodeIds.csv files are pinned under the + *.NodeSet2.csv name. + + Use -Check (default) in CI / pre-commit to fail if the pinned copies have + drifted from the draft repository. Use -Update to refresh the pinned copies + after the draft models change (the draft repository must not be modified). + +.PARAMETER DraftRepo + Path to the checked-out opcua-drafts repository. Defaults to a sibling + 'opcua-drafts2' directory next to this repository's root. + +.PARAMETER Update + Copy the draft artifacts over the pinned copies instead of only verifying. + +.EXAMPLE + pwsh Sync-WotConModels.ps1 -Check + +.EXAMPLE + pwsh Sync-WotConModels.ps1 -Update -DraftRepo D:\git\marcschier\opcua-drafts2 +#> +[CmdletBinding(DefaultParameterSetName = 'Check')] +param( + [Parameter()] + [string]$DraftRepo, + + [Parameter(ParameterSetName = 'Update')] + [switch]$Update, + + [Parameter(ParameterSetName = 'Check')] + [switch]$Check +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$designDir = $PSScriptRoot +# repo root is /src/Opc.Ua.WotCon/Design -> up three levels. +$repoRoot = (Resolve-Path (Join-Path $designDir '..' '..' '..')).Path + +if (-not $DraftRepo) { + $DraftRepo = Join-Path (Split-Path $repoRoot -Parent) 'opcua-drafts2' +} + +# Mapping of draft-repo source -> pinned Design destination file name. +$map = @( + @{ Source = 'core-specs/xregistry/Opc.Ua.XRegistry.NodeSet2.xml'; Dest = 'Opc.Ua.XRegistry.NodeSet2.xml' } + @{ Source = 'core-specs/xregistry/Opc.Ua.XRegistry.NodeIds.csv'; Dest = 'Opc.Ua.XRegistry.NodeSet2.csv' } + @{ Source = 'wot-specs/WoT-Connectivity/Opc.Ua.WoTCon.NodeSet2.xml'; Dest = 'Opc.Ua.WoTCon.NodeSet2.xml' } + @{ Source = 'wot-specs/WoT-Connectivity/Opc.Ua.WoTCon.NodeIds.csv'; Dest = 'Opc.Ua.WoTCon.NodeSet2.csv' } +) + +function Get-NormalizedHash([string]$path) { + if (-not (Test-Path -LiteralPath $path)) { return $null } + # Normalize CRLF/LF and a leading UTF-8 BOM so hashing is line-ending and + # BOM agnostic (the draft repo and this repo may check out with different + # git autocrlf settings). + $bytes = [System.IO.File]::ReadAllBytes($path) + if ($bytes.Length -ge 3 -and $bytes[0] -eq 0xEF -and $bytes[1] -eq 0xBB -and $bytes[2] -eq 0xBF) { + $bytes = $bytes[3..($bytes.Length - 1)] + } + $text = [System.Text.Encoding]::UTF8.GetString($bytes) -replace "`r`n", "`n" + $norm = [System.Text.Encoding]::UTF8.GetBytes($text) + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + return [System.BitConverter]::ToString($sha.ComputeHash($norm)).Replace('-', '') + } + finally { + $sha.Dispose() + } +} + +$doUpdate = $Update.IsPresent +$drift = @() +$missingSource = @() + +foreach ($entry in $map) { + $src = Join-Path $DraftRepo $entry.Source + $dst = Join-Path $designDir $entry.Dest + + if (-not (Test-Path -LiteralPath $src)) { + $missingSource += $src + continue + } + + if ($doUpdate) { + Copy-Item -LiteralPath $src -Destination $dst -Force + Write-Host "Updated $($entry.Dest)" + continue + } + + $srcHash = Get-NormalizedHash $src + $dstHash = Get-NormalizedHash $dst + if ($srcHash -ne $dstHash) { + $drift += $entry.Dest + Write-Warning "DRIFT: $($entry.Dest) differs from draft $($entry.Source)" + } + else { + Write-Host "OK: $($entry.Dest)" + } +} + +if ($missingSource.Count -gt 0) { + Write-Warning "Draft repository not found or incomplete under '$DraftRepo':" + $missingSource | ForEach-Object { Write-Warning " missing: $_" } + Write-Warning "Pass -DraftRepo to point at a checked-out opcua-drafts repository." + exit 2 +} + +if ($doUpdate) { + Write-Host "Model artifacts synchronized. Rebuild Opc.Ua.WotCon and re-run source generation." + exit 0 +} + +if ($drift.Count -gt 0) { + Write-Error "Pinned WoT model artifacts have drifted from the draft repository: $($drift -join ', '). Run this script with -Update to refresh them." + exit 1 +} + +Write-Host "All pinned WoT model artifacts match the draft repository." +exit 0 diff --git a/src/Opc.Ua.WotCon/Opc.Ua.WotCon.csproj b/src/Opc.Ua.WotCon/Opc.Ua.WotCon.csproj index 70db071ff4..3ebef426f1 100644 --- a/src/Opc.Ua.WotCon/Opc.Ua.WotCon.csproj +++ b/src/Opc.Ua.WotCon/Opc.Ua.WotCon.csproj @@ -6,7 +6,7 @@ Opc.Ua.WotCon $(NoWarn);CS1591 enable - OPC UA WoT Connectivity (OPC 10100-1) information model + OPC UA WoT Connectivity 1.1 information model — a registry-first revision (additive registry nodes plus the incorporated, deprecated OPC 10100-1 v1.02 surface) in one namespace. true NugetREADME.md MIT @@ -41,12 +41,40 @@ true - - + + + + + - - + + + Opc.Ua.XRegistry + XRegistry + http://opcfoundation.org/UA/xRegistry/ + + + + + Opc.Ua.WotCon + WotCon + http://opcfoundation.org/UA/WoT-Con/ + + diff --git a/tests/Opc.Ua.Server.TestFramework/ServerTestServices.cs b/tests/Opc.Ua.Server.TestFramework/ServerTestServices.cs index 102afd72f6..1707ceb62e 100644 --- a/tests/Opc.Ua.Server.TestFramework/ServerTestServices.cs +++ b/tests/Opc.Ua.Server.TestFramework/ServerTestServices.cs @@ -322,6 +322,21 @@ public async ValueTask RepublishAsync( lifetime).ConfigureAwait(false); } + public async ValueTask DeleteMonitoredItemsAsync( + RequestHeader requestHeader, + uint subscriptionId, + ArrayOf monitoredItemIds, + CancellationToken ct = default) + { + using var lifetime = new RequestLifetime(ct); + return await m_server.DeleteMonitoredItemsAsync( + SecureChannelContext, + requestHeader, + subscriptionId, + monitoredItemIds, + lifetime).ConfigureAwait(false); + } + public async ValueTask DeleteSubscriptionsAsync( RequestHeader requestHeader, ArrayOf subscriptionIds, diff --git a/tests/Opc.Ua.Server.Tests/Hosting/HostedNodeManagerLifecycleTests.cs b/tests/Opc.Ua.Server.Tests/Hosting/HostedNodeManagerLifecycleTests.cs index 1594934033..60b2956801 100644 --- a/tests/Opc.Ua.Server.Tests/Hosting/HostedNodeManagerLifecycleTests.cs +++ b/tests/Opc.Ua.Server.Tests/Hosting/HostedNodeManagerLifecycleTests.cs @@ -109,6 +109,30 @@ public void ReloadAsyncWithSyncFactoryThrowsWhenNotAttached() Throws.InvalidOperationException); } + [Test] + public void ShadowReloadAsyncWithAsyncFactoryThrowsWhenNotAttached() + { + var hosted = new HostedNodeManagerLifecycle(); + NodeManagerRegistration registration = NewRegistration(); + IAsyncNodeManagerFactory replacement = Mock.Of(); + + Assert.That( + async () => await hosted.ShadowReloadAsync(registration, replacement).ConfigureAwait(false), + Throws.InvalidOperationException); + } + + [Test] + public void ShadowReloadAsyncWithSyncFactoryThrowsWhenNotAttached() + { + var hosted = new HostedNodeManagerLifecycle(); + NodeManagerRegistration registration = NewRegistration(); + INodeManagerFactory replacement = Mock.Of(); + + Assert.That( + async () => await hosted.ShadowReloadAsync(registration, replacement).ConfigureAwait(false), + Throws.InvalidOperationException); + } + [Test] public void RemoveAsyncThrowsWhenNotAttached() { @@ -254,6 +278,46 @@ public async Task ReloadAsyncWithSyncFactoryDelegatesToAttachedLifecycleAsync() Assert.That(actual, Is.SameAs(expected)); } + [Test] + public async Task ShadowReloadAsyncWithAsyncFactoryDelegatesToAttachedLifecycleAsync() + { + var hosted = new HostedNodeManagerLifecycle(); + var inner = new Mock(); + NodeManagerRegistration current = NewRegistration(); + NodeManagerRegistration expected = NewRegistration(); + IAsyncNodeManagerFactory replacement = Mock.Of(); + inner + .Setup(l => l.ShadowReloadAsync(current, replacement, CancellationToken.None)) + .Returns(new ValueTask(expected)); + hosted.Attach(inner.Object); + + NodeManagerRegistration actual = await hosted + .ShadowReloadAsync(current, replacement) + .ConfigureAwait(false); + + Assert.That(actual, Is.SameAs(expected)); + } + + [Test] + public async Task ShadowReloadAsyncWithSyncFactoryDelegatesToAttachedLifecycleAsync() + { + var hosted = new HostedNodeManagerLifecycle(); + var inner = new Mock(); + NodeManagerRegistration current = NewRegistration(); + NodeManagerRegistration expected = NewRegistration(); + INodeManagerFactory replacement = Mock.Of(); + inner + .Setup(l => l.ShadowReloadAsync(current, replacement, CancellationToken.None)) + .Returns(new ValueTask(expected)); + hosted.Attach(inner.Object); + + NodeManagerRegistration actual = await hosted + .ShadowReloadAsync(current, replacement) + .ConfigureAwait(false); + + Assert.That(actual, Is.SameAs(expected)); + } + [Test] public async Task RemoveAsyncDelegatesToAttachedLifecycleAsync() { diff --git a/tests/Opc.Ua.Server.Tests/NodeManager/NodeManagerLifecycleTests.cs b/tests/Opc.Ua.Server.Tests/NodeManager/NodeManagerLifecycleTests.cs index 9c24d8d9eb..ad883d264b 100644 --- a/tests/Opc.Ua.Server.Tests/NodeManager/NodeManagerLifecycleTests.cs +++ b/tests/Opc.Ua.Server.Tests/NodeManager/NodeManagerLifecycleTests.cs @@ -264,9 +264,10 @@ public async Task RegistrationsReturnsDefensiveSnapshotsAsync() /// /// A registration handle that is stale (superseded generation), foreign (unknown /// Id), or spoofed (wrong NodeManager reference for a known Id) - /// must be rejected by both Reload and Remove with the provider's ownership-mismatch - /// message, without invoking a replacement factory and without changing the current - /// generation's registration, routing, value, or namespace state. + /// must be rejected by Reload, ShadowReload, and Remove with the provider's + /// ownership-mismatch message, without invoking a replacement factory and without + /// changing the current generation's registration, routing, value, or namespace + /// state. /// /// /// Thrown when is not a supported value. @@ -277,6 +278,9 @@ public async Task RegistrationsReturnsDefensiveSnapshotsAsync() [TestCase(LifecycleOperation.Remove, MismatchKind.StaleGeneration)] [TestCase(LifecycleOperation.Remove, MismatchKind.ForeignId)] [TestCase(LifecycleOperation.Remove, MismatchKind.ForeignNodeManager)] + [TestCase(LifecycleOperation.ShadowReload, MismatchKind.StaleGeneration)] + [TestCase(LifecycleOperation.ShadowReload, MismatchKind.ForeignId)] + [TestCase(LifecycleOperation.ShadowReload, MismatchKind.ForeignNodeManager)] public async Task RegistrationIdentityMismatchIsRejectedWithoutChangingCurrentGenerationAsync( LifecycleOperation operation, MismatchKind mismatchKind) @@ -315,30 +319,51 @@ public async Task RegistrationIdentityMismatchIsRejectedWithoutChangingCurrentGe const string expectedMessage = "The registration is stale or is not owned by this lifecycle provider."; - if (operation == LifecycleOperation.Reload) + switch (operation) { - var replacementFactory = new Mock(MockBehavior.Strict); - - Assert.That( - async () => await m_server.NodeManagerLifecycle - .ReloadAsync(mismatched, replacementFactory.Object) - .ConfigureAwait(false), - Throws.InvalidOperationException.With.Message.Contains(expectedMessage)); - - replacementFactory.Verify( - f => f.CreateAsync( - It.IsAny(), - It.IsAny(), - It.IsAny()), - Times.Never); - } - else - { - Assert.That( - async () => await m_server.NodeManagerLifecycle - .RemoveAsync(mismatched) - .ConfigureAwait(false), - Throws.InvalidOperationException.With.Message.Contains(expectedMessage)); + case LifecycleOperation.Reload: + { + var replacementFactory = new Mock(MockBehavior.Strict); + + Assert.That( + async () => await m_server.NodeManagerLifecycle + .ReloadAsync(mismatched, replacementFactory.Object) + .ConfigureAwait(false), + Throws.InvalidOperationException.With.Message.Contains(expectedMessage)); + + replacementFactory.Verify( + f => f.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + break; + } + case LifecycleOperation.ShadowReload: + { + var replacementFactory = new Mock(MockBehavior.Strict); + + Assert.That( + async () => await m_server.NodeManagerLifecycle + .ShadowReloadAsync(mismatched, replacementFactory.Object) + .ConfigureAwait(false), + Throws.InvalidOperationException.With.Message.Contains(expectedMessage)); + + replacementFactory.Verify( + f => f.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny()), + Times.Never); + break; + } + default: + Assert.That( + async () => await m_server.NodeManagerLifecycle + .RemoveAsync(mismatched) + .ConfigureAwait(false), + Throws.InvalidOperationException.With.Message.Contains(expectedMessage)); + break; } // The current registration/generation/routing/value/namespace state must be @@ -648,8 +673,9 @@ public async Task ReloadAsyncRejectsOwnedMonitoredItemAndKeepsCurrentManagerAsyn uint urisVersionBefore = await ReadUrisVersionAsync().ConfigureAwait(false); var services = new ServerTestServices(m_server, m_secureChannelContext); - uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId) - .ConfigureAwait(false); + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync( + services, + valueNodeId).ConfigureAwait(false); try { @@ -723,50 +749,811 @@ public async Task RemoveAsyncRejectsOwnedMonitoredItemAndKeepsCurrentManagerAsyn uint urisVersionBefore = await ReadUrisVersionAsync().ConfigureAwait(false); var services = new ServerTestServices(m_server, m_secureChannelContext); - uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId) + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync( + services, + valueNodeId).ConfigureAwait(false); + + try + { + Assert.That( + async () => await m_server.NodeManagerLifecycle + .RemoveAsync(original) + .ConfigureAwait(false), + Throws.InvalidOperationException.With.Message.Contains( + "The NodeManager cannot be reloaded or removed while it owns monitored items.")); + + ArrayOf registrations = + m_server.NodeManagerLifecycle.Registrations; + NodeManagerRegistration survivor = registrations.Find(r => r.Id == original.Id); + Assert.That(survivor, Is.Not.Null); + Assert.That(survivor.Generation, Is.EqualTo(original.Generation)); + Assert.That(ReferenceEquals(survivor.NodeManager, original.NodeManager), Is.True); + + Assert.That( + master.NamespaceManagers[ns].Count(m => ReferenceEquals(m, original.NodeManager)), + Is.EqualTo(1)); + + DataValue value = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(value.WrappedValue.GetInt32(), Is.EqualTo(kGeneration1Value)); + + Assert.That(server.NamespaceUris.Count, Is.EqualTo(namespaceCountBefore)); + uint urisVersionAfter = await ReadUrisVersionAsync().ConfigureAwait(false); + Assert.That(urisVersionAfter, Is.EqualTo(urisVersionBefore)); + } + finally + { + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // With the owning subscription gone, the guard must be lifted: removal succeeds. + await m_server.NodeManagerLifecycle.RemoveAsync(original).ConfigureAwait(false); + + ArrayOf registrationsAfterRemove = + m_server.NodeManagerLifecycle.Registrations; + Assert.That( + CountMatches(registrationsAfterRemove, r => r.Id == original.Id), + Is.Zero); + } + + /// + /// Unlike Reload and Remove, ShadowReload must succeed while the current + /// generation owns an active reporting monitored item: the switch is committed + /// and every new service request (here, Read) is atomically routed to the + /// replacement generation, while the existing monitored item keeps being serviced + /// by the retired (but not yet destroyed) current generation, including for a + /// fresh value pushed directly on that retired generation's own node after the + /// switch. Once the owning subscription is deleted, a later lifecycle operation + /// opportunistically completes retired-generation cleanup and disposes the old + /// generation's address space, without the lifecycle provider ever deleting the + /// client's subscription itself. + /// + [Test] + public async Task ShadowReloadAsyncKeepsActiveMonitoredItemAliveThenDisposesRetiredGenerationAfterDrainAsync() + { + NodeManagerRegistration original = await m_server.NodeManagerLifecycle + .AddRuntimeNodeSetAsync(CreateGenerationOptions(generation: 1)) + .ConfigureAwait(false); + + IServerInternal server = m_server.CurrentInstance; + var master = (MasterNodeManager)server.NodeManager; + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + var valueNodeId = new NodeId(kValueNodeId, ns); + var originalManager = (AsyncCustomNodeManager)original.NodeManager; + const uint clientHandle = 1; + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync( + services, + valueNodeId).ConfigureAwait(false); + + // Drain the initial data-change sample delivered on monitored-item creation so + // the later publish loop only observes the value pushed after the switch. + ArrayOf acknowledgements = default; + (_, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + + try + { + NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle + .ShadowReloadRuntimeNodeSetAsync(original, CreateGenerationOptions(generation: 2)) + .ConfigureAwait(false); + + Assert.That(reloaded.Id, Is.EqualTo(original.Id)); + Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1)); + Assert.That(ReferenceEquals(reloaded.NodeManager, original.NodeManager), Is.False); + + // New service requests must be atomically routed to the replacement; the + // retired generation must no longer be reachable through routing. + Assert.That( + master.NamespaceManagers[ns].Count(m => ReferenceEquals(m, reloaded.NodeManager)), + Is.EqualTo(1)); + Assert.That( + master.NamespaceManagers[ns].Any(m => ReferenceEquals(m, original.NodeManager)), + Is.False); + + DataValue valueAfterSwitch = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(valueAfterSwitch.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(valueAfterSwitch.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + + // The retired generation's own node must still be present, still owned by + // the original manager instance, and unaffected by the switch. + var originalValueState = (BaseVariableState)originalManager.Find(valueNodeId)!; + Assert.That(originalValueState, Is.Not.Null); + Assert.That(originalValueState.Value, Is.EqualTo(kGeneration1Value)); + + ISubscription subscription = server.SubscriptionManager + .GetSubscriptions() + .Single(s => s.Id == subscriptionId); + var tracker = (INodeManagerMonitoredItemTracker)subscription; + Assert.That(tracker.HasMonitoredItems(original.NodeManager), Is.True); + Assert.That(tracker.HasMonitoredItems(reloaded.NodeManager), Is.False); + + // Simulate an internal (device-driven) value push directly on the retired + // generation's own node: it must still reach the existing monitored item. + const int pushedValue = 777; + originalValueState.Value = pushedValue; + originalValueState.Timestamp = DateTimeUtc.Now; + originalValueState.StatusCode = StatusCodes.Good; + originalValueState.UpdateChangeMasks(NodeStateChangeMasks.Value); + await originalValueState + .ClearChangeMasksAsync(server.DefaultSystemContext, includeChildren: false) + .ConfigureAwait(false); + + DataValue? pushedNotification; + (pushedNotification, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + Assert.That(pushedNotification, Is.Not.Null); + Assert.That(pushedNotification!.Value.WrappedValue.GetInt32(), Is.EqualTo(pushedValue)); + + // The replacement generation's own value must remain unaffected by the + // push made directly on the retired generation. + DataValue valueAfterPush = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(valueAfterPush.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + } + finally + { + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // With the owning subscription gone, a later lifecycle operation + // opportunistically finishes retired-generation cleanup: the old generation's + // own address space is torn down (DeleteAddressSpaceAsync empties its + // PredefinedNodes) without the lifecycle provider ever deleting the client's + // (already independently deleted) subscription itself. + NodeManagerRegistration current = m_server.NodeManagerLifecycle.Registrations + .Find(r => r.Id == original.Id); + Assert.That(current, Is.Not.Null); + await m_server.NodeManagerLifecycle.RemoveAsync(current).ConfigureAwait(false); + + Assert.That(originalManager.Find(valueNodeId), Is.Null); + Assert.That( + CountMatches(m_server.NodeManagerLifecycle.Registrations, r => r.Id == original.Id), + Is.Zero); + } + + /// + /// Immediate reload switches new service requests to the replacement, queues + /// BadNodeIdUnknown for every data monitored item owned by the prior generation, + /// and disposes that generation without waiting for the subscription to drain. + /// + [Test] + public async Task ImmediateReloadAsyncReportsBadNodeIdUnknownAndDisposesPriorGenerationAsync() + { + NodeManagerRegistration original = await m_server.NodeManagerLifecycle + .AddRuntimeNodeSetAsync(CreateGenerationOptions(generation: 1)) + .ConfigureAwait(false); + + IServerInternal server = m_server.CurrentInstance; + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + var valueNodeId = new NodeId(kValueNodeId, ns); + var originalManager = (AsyncCustomNodeManager)original.NodeManager; + const uint clientHandle = 1; + + var services = new ServerTestServices(m_server, m_secureChannelContext); + (uint subscriptionId, uint monitoredItemId) = + await CreateSubscriptionAndMonitoredItemAsync( + services, + valueNodeId, + clientHandle).ConfigureAwait(false); + ArrayOf acknowledgements = default; + (_, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + + RequestHeader queueHeader = m_requestHeader; + queueHeader.Timestamp = DateTimeUtc.Now; + ModifyMonitoredItemsResponse queueResponse = await services + .ModifyMonitoredItemsAsync( + queueHeader, + subscriptionId, + TimestampsToReturn.Both, + [ + new MonitoredItemModifyRequest + { + MonitoredItemId = monitoredItemId, + RequestedParameters = new MonitoringParameters + { + ClientHandle = clientHandle, + SamplingInterval = 0, + QueueSize = 5, + DiscardOldest = true + } + } + ]) + .ConfigureAwait(false); + Assert.That(queueResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + + RequestHeader samplingHeader = m_requestHeader; + samplingHeader.Timestamp = DateTimeUtc.Now; + SetMonitoringModeResponse samplingResponse = await services + .SetMonitoringModeAsync( + samplingHeader, + subscriptionId, + MonitoringMode.Sampling, + [monitoredItemId]) + .ConfigureAwait(false); + Assert.That(samplingResponse.Results[0], Is.EqualTo(StatusCodes.Good)); + + try + { + NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle + .ImmediateReloadRuntimeNodeSetAsync( + original, + CreateGenerationOptions(generation: 2)) + .ConfigureAwait(false); + + DataValue current = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(current.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(current.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + + ISubscription subscription = server.SubscriptionManager + .GetSubscriptions() + .Single(s => s.Id == subscriptionId); + var tracker = (INodeManagerMonitoredItemTracker)subscription; + Assert.That(tracker.HasMonitoredItems(original.NodeManager), Is.False); + Assert.That(originalManager.Find(valueNodeId), Is.Null, + "The prior generation must be disposed before immediate reload returns."); + + DataValue? retiredNotification; + (retiredNotification, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + Assert.That(retiredNotification, Is.Not.Null); + Assert.That( + retiredNotification!.Value.StatusCode, + Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1)); + + RequestHeader header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + ModifyMonitoredItemsResponse modifyResponse = await services + .ModifyMonitoredItemsAsync( + header, + subscriptionId, + TimestampsToReturn.Both, + [ + new MonitoredItemModifyRequest + { + MonitoredItemId = monitoredItemId, + RequestedParameters = new MonitoringParameters + { + ClientHandle = clientHandle, + SamplingInterval = 0, + QueueSize = 2, + DiscardOldest = true + } + } + ]) + .ConfigureAwait(false); + Assert.That( + modifyResponse.Results[0].StatusCode, + Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + + header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + SetMonitoringModeResponse modeResponse = await services + .SetMonitoringModeAsync( + header, + subscriptionId, + MonitoringMode.Reporting, + [monitoredItemId]) + .ConfigureAwait(false); + Assert.That(modeResponse.Results[0], Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + + header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + DeleteMonitoredItemsResponse deleteResponse = await services + .DeleteMonitoredItemsAsync(header, subscriptionId, [monitoredItemId]) + .ConfigureAwait(false); + Assert.That(deleteResponse.Results[0], Is.EqualTo(StatusCodes.Good)); + Assert.That(subscription.MonitoredItemCount, Is.Zero); + } + finally + { + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + } + + /// + /// After a ShadowReload, an ownership-sensitive data monitored item still owned by + /// the retired generation must be dispatched to that generation for Modify, + /// SetMonitoringMode (disable/re-enable), and Delete - not to the visible + /// replacement generation that now serves the same namespace. Each operation must + /// succeed (a BadMonitoredItemIdInvalid would prove the ownership defect, + /// where the same-namespace replacement claims but cannot service the retired item), + /// new Reads must be routed to the replacement, notifications pushed on the retired + /// generation's own node must keep flowing, and once the final item drains via Delete + /// the retired generation must be disposed promptly - without any further lifecycle + /// operation. + /// + [Test] + public async Task ShadowReloadedDataMonitoredItemIsModifiableToggleableAndDeletableOnRetiredGenerationThenDrainDisposesItAsync() + { + NodeManagerRegistration original = await m_server.NodeManagerLifecycle + .AddRuntimeNodeSetAsync(CreateGenerationOptions(generation: 1)) + .ConfigureAwait(false); + + IServerInternal server = m_server.CurrentInstance; + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + var valueNodeId = new NodeId(kValueNodeId, ns); + var originalManager = (AsyncCustomNodeManager)original.NodeManager; + const uint clientHandle = 1; + + var services = new ServerTestServices(m_server, m_secureChannelContext); + (uint subscriptionId, uint monitoredItemId) = + await CreateSubscriptionAndMonitoredItemAsync(services, valueNodeId, clientHandle) + .ConfigureAwait(false); + + // Drain the initial data-change sample delivered on monitored-item creation. + ArrayOf acknowledgements = default; + (_, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + + NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle + .ShadowReloadRuntimeNodeSetAsync(original, CreateGenerationOptions(generation: 2)) + .ConfigureAwait(false); + + // The existing item stays owned by the retired generation; the replacement owns none. + ISubscription subscription = server.SubscriptionManager + .GetSubscriptions() + .Single(s => s.Id == subscriptionId); + var tracker = (INodeManagerMonitoredItemTracker)subscription; + Assert.That(tracker.HasMonitoredItems(original.NodeManager), Is.True); + Assert.That(tracker.HasMonitoredItems(reloaded.NodeManager), Is.False); + + // New Reads are routed to the replacement generation. + DataValue afterSwitch = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(afterSwitch.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(afterSwitch.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + + // Old notifications continue: a value pushed on the retired node still arrives. + await PushRetiredValueAsync(server, originalManager, valueNodeId, 4242).ConfigureAwait(false); + DataValue? pushed; + (pushed, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + Assert.That(pushed!.Value.WrappedValue.GetInt32(), Is.EqualTo(4242)); + + // (1) Modify the retired-owned item - must be routed to the retired generation. + RequestHeader header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + ArrayOf itemsToModify = + [ + new MonitoredItemModifyRequest + { + MonitoredItemId = monitoredItemId, + RequestedParameters = new MonitoringParameters + { + ClientHandle = clientHandle, + SamplingInterval = 0, + QueueSize = 5, + DiscardOldest = true + } + } + ]; + ModifyMonitoredItemsResponse modifyResponse = await services + .ModifyMonitoredItemsAsync(header, subscriptionId, TimestampsToReturn.Both, itemsToModify) + .ConfigureAwait(false); + Assert.That(modifyResponse.Results.Count, Is.EqualTo(1)); + Assert.That(modifyResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good), + "Modify of a shadow-retired data monitored item must be routed to its owning " + + "(retired) generation and succeed."); + + // (2) Disable then re-enable the retired-owned item. + header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + SetMonitoringModeResponse disableResponse = await services + .SetMonitoringModeAsync(header, subscriptionId, MonitoringMode.Disabled, [monitoredItemId]) + .ConfigureAwait(false); + Assert.That(disableResponse.Results.Count, Is.EqualTo(1)); + Assert.That(disableResponse.Results[0], Is.EqualTo(StatusCodes.Good), + "Disabling a shadow-retired data monitored item must be routed to its owning generation."); + + header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + SetMonitoringModeResponse enableResponse = await services + .SetMonitoringModeAsync(header, subscriptionId, MonitoringMode.Reporting, [monitoredItemId]) + .ConfigureAwait(false); + Assert.That(enableResponse.Results[0], Is.EqualTo(StatusCodes.Good), + "Re-enabling a shadow-retired data monitored item must be routed to its owning generation."); + + // The re-enabled item still delivers a fresh value pushed on the retired node. + await PushRetiredValueAsync(server, originalManager, valueNodeId, 5353).ConfigureAwait(false); + (pushed, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + Assert.That(pushed!.Value.WrappedValue.GetInt32(), Is.EqualTo(5353)); + + // (3) Delete the retired-owned item - must be routed to the retired generation, + // draining it. Without owner-based routing the same-namespace replacement claims + // the item and returns BadMonitoredItemIdInvalid, so the retired item never drains. + header = m_requestHeader; + header.Timestamp = DateTimeUtc.Now; + DeleteMonitoredItemsResponse deleteResponse = await services + .DeleteMonitoredItemsAsync(header, subscriptionId, [monitoredItemId]) + .ConfigureAwait(false); + Assert.That(deleteResponse.Results.Count, Is.EqualTo(1)); + Assert.That(deleteResponse.Results[0], Is.EqualTo(StatusCodes.Good), + "Delete of a shadow-retired data monitored item must be routed to its owning generation."); + Assert.That(tracker.HasMonitoredItems(original.NodeManager), Is.False); + + // The replacement generation is unaffected and still serves Reads. + DataValue afterDrain = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(afterDrain.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + + // Prompt cleanup: with the final item drained, the retired generation is disposed + // WITHOUT any further lifecycle operation - its own address space is torn down. + await AssertRetiredGenerationDisposedAsync(originalManager, valueNodeId).ConfigureAwait(false); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + /// + /// After a ShadowReload, a subscription that still owns a data monitored item created + /// on the retired generation must transfer to another session with that item routed to + /// the retired generation. The item remains owned by the retired generation after the + /// transfer and keeps delivering values pushed on the retired generation's own node. + /// + [Test] + public async Task ShadowReloadedDataMonitoredItemSurvivesSubscriptionTransferOnRetiredGenerationAsync() + { + NodeManagerRegistration original = await m_server.NodeManagerLifecycle + .AddRuntimeNodeSetAsync(CreateGenerationOptions(generation: 1)) .ConfigureAwait(false); - try - { - Assert.That( + IServerInternal server = m_server.CurrentInstance; + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + var valueNodeId = new NodeId(kValueNodeId, ns); + var originalManager = (AsyncCustomNodeManager)original.NodeManager; + const uint clientHandle = 1; + + var servicesA = new ServerTestServices(m_server, m_secureChannelContext); + (uint subscriptionId, _) = + await CreateSubscriptionAndMonitoredItemAsync(servicesA, valueNodeId, clientHandle) + .ConfigureAwait(false); + + ArrayOf acknowledgements = default; + (_, acknowledgements) = await PublishForDataChangeAsync( + servicesA, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + + NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle + .ShadowReloadRuntimeNodeSetAsync(original, CreateGenerationOptions(generation: 2)) + .ConfigureAwait(false); + + ISubscription subscription = server.SubscriptionManager + .GetSubscriptions() + .Single(s => s.Id == subscriptionId); + var tracker = (INodeManagerMonitoredItemTracker)subscription; + Assert.That(tracker.HasMonitoredItems(original.NodeManager), Is.True); + + // Session B activates on a secured channel so the subscription (owned by an + // anonymous identity) can be transferred to it: the server only permits an + // anonymous-identity transfer over a Sign/SignAndEncrypt channel. + (RequestHeader headerB, SecureChannelContext channelB) = await m_server + .CreateAndActivateSessionAsync( + $"{TestContext.CurrentContext.Test.Name}_SessionB", + useSecurity: true) + .ConfigureAwait(false); + try + { + var servicesB = new ServerTestServices(m_server, channelB); + + headerB.Timestamp = DateTimeUtc.Now; + TransferSubscriptionsResponse transferResponse = await servicesB + .TransferSubscriptionsAsync(headerB, [subscriptionId], sendInitialValues: false) + .ConfigureAwait(false); + Assert.That(transferResponse.Results.Count, Is.EqualTo(1)); + Assert.That(StatusCode.IsGood(transferResponse.Results[0].StatusCode), Is.True, + "Transfer of a subscription owning a shadow-retired data monitored item must succeed."); + + // The item remains owned by the retired generation after the transfer. + Assert.That(tracker.HasMonitoredItems(original.NodeManager), Is.True); + Assert.That(tracker.HasMonitoredItems(reloaded.NodeManager), Is.False); + + // The transferred item keeps delivering values pushed on the retired node, + // proving the transfer was routed to the retired generation that owns it. + await PushRetiredValueAsync(server, originalManager, valueNodeId, 6464).ConfigureAwait(false); + (DataValue? pushed, _) = await PublishForDataChangeOnSessionAsync( + servicesB, + headerB, + subscriptionId, + default, + clientHandle).ConfigureAwait(false); + Assert.That(pushed!.Value.WrappedValue.GetInt32(), Is.EqualTo(6464)); + + headerB.Timestamp = DateTimeUtc.Now; + ArrayOf subscriptionIds = [subscriptionId]; + DeleteSubscriptionsResponse deleteResponse = await servicesB + .DeleteSubscriptionsAsync(headerB, subscriptionIds) + .ConfigureAwait(false); + Assert.That(deleteResponse.Results[0], Is.EqualTo(StatusCodes.Good)); + } + finally + { + headerB.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(channelB, headerB, true, RequestLifetime.None) + .ConfigureAwait(false); + } + + // The retired generation drains once its subscription is gone; prompt cleanup tears + // down its address space without any further lifecycle operation. + await AssertRetiredGenerationDisposedAsync(originalManager, valueNodeId).ConfigureAwait(false); + } + + /// + /// When the replacement factory throws during ShadowReload's preparation phase + /// (before the routing switch is ever committed), the sentinel exception must + /// propagate unchanged and the current generation must remain fully active: + /// registration, routing, and value state are entirely unaffected, exactly as for + /// a fail-closed Reload failure. + /// + [Test] + public async Task ShadowReloadAsyncWhenReplacementFactoryThrowsKeepsCurrentManagerAsync() + { + NodeManagerRegistration original = await m_server.NodeManagerLifecycle + .AddRuntimeNodeSetAsync(CreateGenerationOptions(generation: 1)) + .ConfigureAwait(false); + + IServerInternal server = m_server.CurrentInstance; + var master = (MasterNodeManager)server.NodeManager; + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + var valueNodeId = new NodeId(kValueNodeId, ns); + int namespaceCountBefore = server.NamespaceUris.Count; + uint urisVersionBefore = await ReadUrisVersionAsync().ConfigureAwait(false); + + var replacementFactory = new Mock(MockBehavior.Strict); + replacementFactory + .Setup(f => f.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .Throws(new SentinelException()); + + Assert.That( + async () => await m_server.NodeManagerLifecycle + .ShadowReloadAsync(original, replacementFactory.Object) + .ConfigureAwait(false), + Throws.TypeOf()); + + ArrayOf registrations = m_server.NodeManagerLifecycle.Registrations; + NodeManagerRegistration survivor = registrations.Find(r => r.Id == original.Id); + Assert.That(survivor, Is.Not.Null); + Assert.That(survivor.Generation, Is.EqualTo(original.Generation)); + Assert.That(ReferenceEquals(survivor.NodeManager, original.NodeManager), Is.True); + + Assert.That( + master.NamespaceManagers[ns].Count(m => ReferenceEquals(m, original.NodeManager)), + Is.EqualTo(1)); + + DataValue value = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(value.WrappedValue.GetInt32(), Is.EqualTo(kGeneration1Value)); + + Assert.That(server.NamespaceUris.Count, Is.EqualTo(namespaceCountBefore)); + uint urisVersionAfter = await ReadUrisVersionAsync().ConfigureAwait(false); + Assert.That(urisVersionAfter, Is.EqualTo(urisVersionBefore)); + } + + /// + /// When the replacement's structural commit succeeds but a subsequent rollback + /// attempt during a later, unrelated failure also fails, ShadowReload must behave + /// exactly like Reload: the replacement generation is retained live and reported + /// from for retry or removal, + /// both underlying failures are reported, and once the transient failures are + /// cleared a subsequent Remove of the retained registration (and its owner) + /// completes cleanly. + /// + [Test] + public async Task ShadowReloadAsyncWhenReplacementRollbackFailsRetainsReplacementGenerationAsync() + { + const string OwnerNamespaceUri = + "urn:opcfoundation.org:Tests:NodeManagerLifecycle:ShadowReloadRollbackOwner"; + const string ReloadedNamespaceUri = + "urn:opcfoundation.org:Tests:NodeManagerLifecycle:ShadowReloadRollbackRetained"; + const string AddReferencesFailure = + "Replacement AddReferencesAsync failed."; + const string DeleteReferenceFailure = + "Replacement rollback DeleteReferenceAsync failed."; + + Mock ownerManager = + CreateLifecycleNodeManager(OwnerNamespaceUri); + Mock ownerDisposable = ownerManager.As(); + var ownerFactory = new Mock(); + ownerFactory + .Setup(value => value.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(ownerManager.Object); + NodeManagerRegistration ownerRegistration = await m_server.NodeManagerLifecycle + .AddAsync(ownerFactory.Object) + .ConfigureAwait(false); + + IServerInternal server = m_server.CurrentInstance; + var master = (MasterNodeManager)server.NodeManager; + ushort ownerNamespaceIndex = (ushort)server.NamespaceUris.GetIndex( + OwnerNamespaceUri); + var ownerSourceId = new NodeId(2101, ownerNamespaceIndex); + object ownerHandle = new(); + int deleteReferenceCalls = 0; + bool failRollbackDelete = true; + ownerManager + .Setup(manager => manager.GetManagerHandleAsync( + ownerSourceId, + It.IsAny())) + .Returns(new ValueTask(ownerHandle)); + ownerManager + .Setup(manager => manager.DeleteReferenceAsync( + ownerHandle, + ReferenceTypeIds.HasComponent, + false, + ObjectIds.Server, + false, + It.IsAny())) + .Returns(() => + { + deleteReferenceCalls++; + if (failRollbackDelete && deleteReferenceCalls >= 2) + { + return new ValueTask( + Task.FromException( + new SentinelException(DeleteReferenceFailure))); + } + return new ValueTask(ServiceResult.Good); + }); + + Mock originalManager = + CreateLifecycleNodeManager(ReloadedNamespaceUri); + Mock originalDisposable = + originalManager.As(); + originalManager + .Setup(manager => manager.CreateAddressSpaceAsync( + It.IsAny>>(), + It.IsAny())) + .Callback< + IDictionary>, + CancellationToken>((externalReferences, _) => externalReferences[ownerSourceId] = + [ + new NodeStateReference( + ReferenceTypeIds.HasComponent, + false, + ObjectIds.Server) + ]) + .Returns(default(ValueTask)); + originalManager + .As() + .Setup(participant => participant.PrepareReloadAsync( + It.IsAny(), + It.IsAny())) + .Returns(new ValueTask>([])); + var originalFactory = new Mock(); + originalFactory + .Setup(value => value.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(originalManager.Object); + NodeManagerRegistration originalRegistration = + await m_server.NodeManagerLifecycle + .AddAsync(originalFactory.Object) + .ConfigureAwait(false); + + Mock replacementManager = + CreateLifecycleNodeManager(ReloadedNamespaceUri); + Mock replacementDisposable = + replacementManager.As(); + replacementManager + .Setup(manager => manager.CreateAddressSpaceAsync( + It.IsAny>>(), + It.IsAny())) + .Callback< + IDictionary>, + CancellationToken>((externalReferences, _) => externalReferences[ownerSourceId] = + [ + new NodeStateReference( + ReferenceTypeIds.HasComponent, + false, + ObjectIds.Server) + ]) + .Returns(default(ValueTask)); + bool failReplacementAddReferences = true; + replacementManager + .Setup(manager => manager.AddReferencesAsync( + It.IsAny>>(), + It.IsAny())) + .Returns(() => failReplacementAddReferences + ? new ValueTask(Task.FromException( + new SentinelException(AddReferencesFailure))) + : default); + var replacementFactory = new Mock(); + replacementFactory + .Setup(value => value.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(replacementManager.Object); + + NodeManagerReloadCommittedException exception = + Assert.ThrowsAsync( async () => await m_server.NodeManagerLifecycle - .RemoveAsync(original) - .ConfigureAwait(false), - Throws.InvalidOperationException.With.Message.Contains( - "The NodeManager cannot be reloaded or removed while it owns monitored items.")); - - ArrayOf registrations = - m_server.NodeManagerLifecycle.Registrations; - NodeManagerRegistration survivor = registrations.Find(r => r.Id == original.Id); - Assert.That(survivor, Is.Not.Null); - Assert.That(survivor.Generation, Is.EqualTo(original.Generation)); - Assert.That(ReferenceEquals(survivor.NodeManager, original.NodeManager), Is.True); - - Assert.That( - master.NamespaceManagers[ns].Count(m => ReferenceEquals(m, original.NodeManager)), - Is.EqualTo(1)); + .ShadowReloadAsync( + originalRegistration, + replacementFactory.Object) + .ConfigureAwait(false)); - DataValue value = await ReadValueAsync(valueNodeId).ConfigureAwait(false); - Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good)); - Assert.That(value.WrappedValue.GetInt32(), Is.EqualTo(kGeneration1Value)); + Assert.That( + exception.Message, + Does.Contain("replacement generation was retained")); + Assert.That(exception.InnerException, Is.TypeOf()); + string[] failureMessages = [.. ((AggregateException)exception.InnerException!) + .Flatten() + .InnerExceptions + .Select(failure => failure.Message)]; + Assert.That(failureMessages, Does.Contain(AddReferencesFailure)); + Assert.That(failureMessages, Does.Contain(DeleteReferenceFailure)); - Assert.That(server.NamespaceUris.Count, Is.EqualTo(namespaceCountBefore)); - uint urisVersionAfter = await ReadUrisVersionAsync().ConfigureAwait(false); - Assert.That(urisVersionAfter, Is.EqualTo(urisVersionBefore)); - } - finally - { - await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); - } + NodeManagerRegistration retainedRegistration = + m_server.NodeManagerLifecycle.Registrations.Find(registration => + ReferenceEquals(registration.NodeManager, replacementManager.Object)); + Assert.That(retainedRegistration, Is.Not.Null); + Assert.That(retainedRegistration.Id, Is.EqualTo(originalRegistration.Id)); + Assert.That( + retainedRegistration.Generation, + Is.EqualTo(originalRegistration.Generation + 1)); + Assert.That( + master.AsyncNodeManagers.Any(manager => + ReferenceEquals(manager, originalManager.Object)), + Is.False); + Assert.That( + master.AsyncNodeManagers.Any(manager => + ReferenceEquals(manager, replacementManager.Object)), + Is.True); + originalDisposable.Verify(manager => manager.Dispose(), Times.Never); + replacementDisposable.Verify(manager => manager.Dispose(), Times.Never); - // With the owning subscription gone, the guard must be lifted: removal succeeds. - await m_server.NodeManagerLifecycle.RemoveAsync(original).ConfigureAwait(false); + failReplacementAddReferences = false; + failRollbackDelete = false; + await m_server.NodeManagerLifecycle + .RemoveAsync(retainedRegistration) + .ConfigureAwait(false); + await m_server.NodeManagerLifecycle + .RemoveAsync(ownerRegistration) + .ConfigureAwait(false); - ArrayOf registrationsAfterRemove = - m_server.NodeManagerLifecycle.Registrations; - Assert.That( - CountMatches(registrationsAfterRemove, r => r.Id == original.Id), - Is.Zero); + originalManager.Verify( + manager => manager.DeleteAddressSpaceAsync( + CancellationToken.None), + Times.Once); + originalDisposable.Verify(manager => manager.Dispose(), Times.Once); + replacementManager.Verify( + manager => manager.DeleteAddressSpaceAsync( + CancellationToken.None), + Times.Once); + replacementDisposable.Verify(manager => manager.Dispose(), Times.Once); + ownerDisposable.Verify(manager => manager.Dispose(), Times.Once); + Assert.That(m_server.NodeManagerLifecycle.Registrations, Is.Empty); } /// @@ -1124,8 +1911,8 @@ public async Task ReloadAsyncWhenRetiredManagerCleanupFailsKeepsReplacementAndRe .ReturnsAsync(replacementManager.Object); failRetiredSessionClosing = true; - InvalidOperationException exception = - Assert.ThrowsAsync( + NodeManagerReloadCommittedException exception = + Assert.ThrowsAsync( async () => await m_server.NodeManagerLifecycle .ReloadAsync(original, replacementFactory.Object) .ConfigureAwait(false)); @@ -1135,6 +1922,8 @@ public async Task ReloadAsyncWhenRetiredManagerCleanupFailsKeepsReplacementAndRe Does.Contain("replacement NodeManager is live")); Assert.That(exception.InnerException, Is.TypeOf()); Assert.That(exception.InnerException!.Message, Is.EqualTo(ExpectedMessage)); + Assert.That(exception.Registration.Id, Is.EqualTo(original.Id)); + Assert.That(exception.Registration.Generation, Is.EqualTo(original.Generation + 1)); ArrayOf registrations = m_server.NodeManagerLifecycle.Registrations; @@ -1453,8 +2242,8 @@ await m_server.NodeManagerLifecycle It.IsAny())) .ReturnsAsync(replacementManager.Object); - InvalidOperationException exception = - Assert.ThrowsAsync( + NodeManagerReloadCommittedException exception = + Assert.ThrowsAsync( async () => await m_server.NodeManagerLifecycle .ReloadAsync( originalRegistration, @@ -1563,6 +2352,34 @@ public Task LifecycleArgumentGuardsRejectNullInputsAsync() .ConfigureAwait(false)); Assert.That(exception.ParamName, Is.EqualTo("registration")); + exception = Assert.ThrowsAsync( + async () => await lifecycle + .ShadowReloadAsync( + null!, + (IAsyncNodeManagerFactory)null!) + .ConfigureAwait(false)); + Assert.That(exception.ParamName, Is.EqualTo("replacement")); + + exception = Assert.ThrowsAsync( + async () => await lifecycle + .ShadowReloadAsync( + null!, + (INodeManagerFactory)null!) + .ConfigureAwait(false)); + Assert.That(exception.ParamName, Is.EqualTo("replacement")); + + exception = Assert.ThrowsAsync( + async () => await lifecycle + .ShadowReloadAsync(null!, asyncFactory) + .ConfigureAwait(false)); + Assert.That(exception.ParamName, Is.EqualTo("registration")); + + exception = Assert.ThrowsAsync( + async () => await lifecycle + .ShadowReloadAsync(null!, syncFactory) + .ConfigureAwait(false)); + Assert.That(exception.ParamName, Is.EqualTo("registration")); + exception = Assert.ThrowsAsync( async () => await lifecycle .RemoveAsync(null!) @@ -2067,6 +2884,228 @@ private async Task DeleteSubscriptionAsync(ServerTestServices services, uint sub Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good)); } + /// + /// Creates a subscription with a single reporting data monitored item on + /// and returns both the subscription id and the + /// server-assigned monitored item id (needed to target Modify, SetMonitoringMode, + /// and Delete at that specific item). + /// + private async Task<(uint SubscriptionId, uint MonitoredItemId)> + CreateSubscriptionAndMonitoredItemAsync( + ServerTestServices services, + NodeId nodeId, + uint clientHandle) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscriptionResponse = await services + .CreateSubscriptionAsync(requestHeader, 100, 100, 10, 0, true, 0) + .ConfigureAwait(false); + uint subscriptionId = subscriptionResponse.SubscriptionId; + + ArrayOf monitoredItems = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId + { + NodeId = nodeId, + AttributeId = Attributes.Value + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = clientHandle, + SamplingInterval = 0, + QueueSize = 1, + DiscardOldest = true + } + } + ]; + + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse createItemsResponse = await services + .CreateMonitoredItemsAsync(requestHeader, subscriptionId, TimestampsToReturn.Both, monitoredItems) + .ConfigureAwait(false); + + Assert.That(createItemsResponse.Results.Count, Is.EqualTo(1)); + Assert.That(createItemsResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + + return (subscriptionId, createItemsResponse.Results[0].MonitoredItemId); + } + + /// + /// Pushes a fresh value directly onto a retired generation's own node, simulating an + /// internal (device-driven) update so tests can prove the retired generation still + /// services its existing monitored items after a ShadowReload switch. + /// + private static async Task PushRetiredValueAsync( + IServerInternal server, + AsyncCustomNodeManager retiredManager, + NodeId nodeId, + int value) + { + var state = (BaseVariableState)retiredManager.Find(nodeId)!; + state.Value = value; + state.Timestamp = DateTimeUtc.Now; + state.StatusCode = StatusCodes.Good; + state.UpdateChangeMasks(NodeStateChangeMasks.Value); + await state + .ClearChangeMasksAsync(server.DefaultSystemContext, includeChildren: false) + .ConfigureAwait(false); + } + + /// + /// Polls (bounded) until the retired generation's own address space has been torn + /// down (its PredefinedNodes emptied), proving the retired generation is + /// disposed promptly once its last monitored item drains - without any further + /// lifecycle operation being invoked by the test. + /// + private static async Task AssertRetiredGenerationDisposedAsync( + AsyncCustomNodeManager retiredManager, + NodeId valueNodeId) + { + const int MaxAttempts = 50; + for (int attempt = 0; attempt < MaxAttempts; attempt++) + { + if (retiredManager.Find(valueNodeId) is null) + { + return; + } + await Task.Delay(100).ConfigureAwait(false); + } + + Assert.That( + retiredManager.Find(valueNodeId), + Is.Null, + "The shadow-retired generation must be disposed promptly once its last monitored " + + "item drains, without any further lifecycle operation."); + } + + /// + /// Publishes on using the given session's request + /// header in a bounded loop until a carrying + /// arrives. Used to prove a transferred monitored item + /// keeps being serviced by the (retired) generation that owns it, from a second session. + /// + private async Task<(DataValue? Value, ArrayOf Acknowledgements)> + PublishForDataChangeOnSessionAsync( + ServerTestServices services, + RequestHeader sessionRequestHeader, + uint subscriptionId, + ArrayOf acknowledgements, + uint clientHandle) + { + const int MaxPublishAttempts = 20; + DataValue? value = null; + + for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++) + { + sessionRequestHeader.Timestamp = DateTimeUtc.Now; + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(sessionRequestHeader, acknowledgements, timeoutCts.Token) + .ConfigureAwait(false); + + Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId)); + + acknowledgements = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + + if (response.NotificationMessage is { } message) + { + foreach (ExtensionObject notificationData in message.NotificationData) + { + if (notificationData.TryGetValue(out DataChangeNotification dcn)) + { + foreach (MonitoredItemNotification item in dcn.MonitoredItems) + { + if (item.ClientHandle == clientHandle) + { + value = item.Value; + } + } + } + } + } + } + + Assert.That( + value, + Is.Not.Null, + $"No data-change notification for client handle {clientHandle} on subscription " + + $"{subscriptionId} arrived within {MaxPublishAttempts} bounded publish attempts."); + return (value, acknowledgements); + } + + /// + /// Publishes on in a bounded loop, acknowledging + /// previously delivered sequence numbers on each call, until a + /// carrying + /// arrives. Used to prove that a monitored item keeps being serviced (by whichever + /// NodeManager generation owns it) after a live lifecycle switch. + /// + private async Task<(DataValue? Value, ArrayOf Acknowledgements)> + PublishForDataChangeAsync( + ServerTestServices services, + uint subscriptionId, + ArrayOf acknowledgements, + uint clientHandle) + { + const int MaxPublishAttempts = 20; + DataValue? value = null; + + for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token) + .ConfigureAwait(false); + + Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId)); + + acknowledgements = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + + if (response.NotificationMessage is { } message) + { + foreach (ExtensionObject notificationData in message.NotificationData) + { + if (notificationData.TryGetValue(out DataChangeNotification dcn)) + { + foreach (MonitoredItemNotification item in dcn.MonitoredItems) + { + if (item.ClientHandle == clientHandle) + { + value = item.Value; + } + } + } + } + } + } + + Assert.That( + value, + Is.Not.Null, + $"No data-change notification for client handle {clientHandle} on subscription " + + $"{subscriptionId} arrived within {MaxPublishAttempts} bounded publish attempts."); + return (value, acknowledgements); + } + /// /// Builds a reporting event monitored item on that /// selects EventType, SourceNode, SourceName, and @@ -2400,7 +3439,8 @@ private static Mock CreateLifecycleNodeManager( public enum LifecycleOperation { Reload, - Remove + Remove, + ShadowReload } /// diff --git a/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetLifecycleTests.cs b/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetLifecycleTests.cs index 5078ccf79a..651c83f2b9 100644 --- a/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetLifecycleTests.cs +++ b/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/RuntimeNodeSetLifecycleTests.cs @@ -32,6 +32,7 @@ using System.IO; using System.Linq; using System.Text; +using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Moq; @@ -919,6 +920,135 @@ public async Task ReloadRuntimeNodeSetAsyncReplacesGenerationAndPreservesNamespa Assert.That(originalOnlyValue.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); } + /// + /// Unlike Reload, ShadowReload must succeed while the current generation owns an + /// active reporting monitored item. New Read and Browse requests must be + /// atomically routed to the replacement generation as soon as the switch is + /// committed, while the monitored item created before the switch keeps being + /// serviced by the retired (but not yet destroyed) current generation, including + /// for a value pushed directly on that retired generation's own node after the + /// switch. Once the owning subscription is deleted, a later lifecycle operation + /// completes retired-generation cleanup and disposes the old generation's address + /// space. + /// + [Test] + public async Task ShadowReloadRuntimeNodeSetAsyncRoutesNewRequestsToReplacementWhileOldMonitoredItemStaysActiveAsync() + { + IServerInternal server = m_server.CurrentInstance; + var master = (MasterNodeManager)server.NodeManager; + + NodeManagerRegistration original = await m_server.NodeManagerLifecycle + .AddRuntimeNodeSetAsync(CreateOptions(generation: 1)) + .ConfigureAwait(false); + + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + var rootNodeId = new NodeId(kRootNodeId, ns); + var valueNodeId = new NodeId(kValueNodeId, ns); + var replacementOnlyNodeId = new NodeId(kReplacementOnlyNodeId, ns); + var originalManager = (AsyncCustomNodeManager)original.NodeManager; + const uint clientHandle = 1; + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId) + .ConfigureAwait(false); + + // Drain the initial data-change sample delivered on monitored-item creation so + // the later publish loop only observes the value pushed after the switch. + ArrayOf acknowledgements = default; + (_, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + + try + { + NodeManagerRegistration reloaded = await m_server.NodeManagerLifecycle + .ShadowReloadRuntimeNodeSetAsync(original, CreateOptions(generation: 2)) + .ConfigureAwait(false); + + Assert.That(reloaded.Id, Is.EqualTo(original.Id)); + Assert.That(reloaded.Generation, Is.EqualTo(original.Generation + 1)); + Assert.That(ReferenceEquals(reloaded.NodeManager, original.NodeManager), Is.False); + + // New requests must be atomically routed to the replacement generation. + Assert.That( + master.NamespaceManagers[ns].Count(m => ReferenceEquals(m, reloaded.NodeManager)), + Is.EqualTo(1)); + Assert.That( + master.NamespaceManagers[ns].Any(m => ReferenceEquals(m, original.NodeManager)), + Is.False); + + DataValue valueAfterSwitch = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(valueAfterSwitch.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(valueAfterSwitch.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + + BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false); + Assert.That(rootBrowse.Results.Count, Is.EqualTo(1)); + ArrayOf rootReferences = rootBrowse.Results[0].References; + Assert.That( + rootReferences.Contains( + r => r.BrowseName.Equals(new QualifiedName(kReplacementOnlyBrowseName, ns))), + Is.True); + Assert.That( + rootReferences.Contains( + r => r.BrowseName.Equals(new QualifiedName(kOriginalOnlyBrowseName, ns))), + Is.False); + NodeState replacementNode = await server.NodeManager + .FindNodeInAddressSpaceAsync(replacementOnlyNodeId) + .ConfigureAwait(false); + Assert.That(replacementNode, Is.Not.Null); + + // The retired generation's own node must still be present and unaffected. + var originalValueState = (BaseVariableState)originalManager.Find(valueNodeId)!; + Assert.That(originalValueState, Is.Not.Null); + Assert.That(originalValueState.Value, Is.EqualTo(kGeneration1Value)); + + // Simulate an internal (device-driven) value push directly on the retired + // generation's own node: it must still reach the existing monitored item. + const int pushedValue = 888; + originalValueState.Value = pushedValue; + originalValueState.Timestamp = DateTimeUtc.Now; + originalValueState.StatusCode = StatusCodes.Good; + originalValueState.UpdateChangeMasks(NodeStateChangeMasks.Value); + await originalValueState + .ClearChangeMasksAsync(server.DefaultSystemContext, includeChildren: false) + .ConfigureAwait(false); + + DataValue? pushedNotification; + (pushedNotification, acknowledgements) = await PublishForDataChangeAsync( + services, + subscriptionId, + acknowledgements, + clientHandle).ConfigureAwait(false); + Assert.That(pushedNotification, Is.Not.Null); + Assert.That(pushedNotification!.Value.WrappedValue.GetInt32(), Is.EqualTo(pushedValue)); + + // The replacement generation's own value must remain unaffected by the + // push made directly on the retired generation. + DataValue valueAfterPush = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(valueAfterPush.WrappedValue.GetInt32(), Is.EqualTo(kGeneration2Value)); + } + finally + { + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // With the owning subscription gone, a later lifecycle operation + // opportunistically finishes retired-generation cleanup: the old generation's + // own address space is torn down without ever deleting the client's + // (already independently deleted) subscription itself. + NodeManagerRegistration current = m_server.NodeManagerLifecycle.Registrations + .Find(r => r.Id == original.Id); + Assert.That(current, Is.Not.Null); + await m_server.NodeManagerLifecycle.RemoveAsync(current).ConfigureAwait(false); + + Assert.That(originalManager.Find(valueNodeId), Is.Null); + Assert.That( + CountMatches(m_server.NodeManagerLifecycle.Registrations, r => r.Id == original.Id), + Is.Zero); + } + /// /// Removing a live registration must unroute its NodeManager and unregister it, /// leaving its nodes unreachable through direct lookup, browse, read, and translate, @@ -1318,6 +1448,130 @@ private async Task ReadNamespaceArrayAsync() return value.WrappedValue.GetStringArray().ToArray(); } + /// + /// Creates a subscription and a single reporting, data-change monitored item on the + /// given node's Value attribute. + /// + private async Task CreateSubscriptionWithMonitoredItemAsync( + ServerTestServices services, + NodeId nodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscriptionResponse = await services + .CreateSubscriptionAsync(requestHeader, 100, 100, 10, 0, true, 0) + .ConfigureAwait(false); + uint subscriptionId = subscriptionResponse.SubscriptionId; + + ArrayOf monitoredItems = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId + { + NodeId = nodeId, + AttributeId = Attributes.Value + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, + SamplingInterval = 0, + QueueSize = 1, + DiscardOldest = true + } + } + ]; + + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse createItemsResponse = await services + .CreateMonitoredItemsAsync(requestHeader, subscriptionId, TimestampsToReturn.Both, monitoredItems) + .ConfigureAwait(false); + + Assert.That(createItemsResponse.Results.Count, Is.EqualTo(1)); + Assert.That(createItemsResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + + return subscriptionId; + } + + /// + /// Deletes the given subscription so it no longer owns any monitored items. + /// + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf subscriptionIds = [subscriptionId]; + DeleteSubscriptionsResponse response = await services + .DeleteSubscriptionsAsync(requestHeader, subscriptionIds) + .ConfigureAwait(false); + Assert.That(response.Results.Count, Is.EqualTo(1)); + Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good)); + } + + /// + /// Publishes on in a bounded loop, acknowledging + /// previously delivered sequence numbers on each call, until a + /// carrying + /// arrives. Used to prove that a monitored item keeps being serviced (by whichever + /// NodeManager generation owns it) after a live lifecycle switch. + /// + private async Task<(DataValue? Value, ArrayOf Acknowledgements)> + PublishForDataChangeAsync( + ServerTestServices services, + uint subscriptionId, + ArrayOf acknowledgements, + uint clientHandle) + { + const int MaxPublishAttempts = 20; + DataValue? value = null; + + for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token) + .ConfigureAwait(false); + + Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId)); + + acknowledgements = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, + SequenceNumber = sequenceNumber + }); + + if (response.NotificationMessage is { } message) + { + foreach (ExtensionObject notificationData in message.NotificationData) + { + if (notificationData.TryGetValue(out DataChangeNotification dcn)) + { + foreach (MonitoredItemNotification item in dcn.MonitoredItems) + { + if (item.ClientHandle == clientHandle) + { + value = item.Value; + } + } + } + } + } + } + + Assert.That( + value, + Is.Not.Null, + $"No data-change notification for client handle {clientHandle} on subscription " + + $"{subscriptionId} arrived within {MaxPublishAttempts} bounded publish attempts."); + return (value, acknowledgements); + } + /// /// Browses a single node with the standard hierarchical-references template used /// throughout this fixture. diff --git a/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs b/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs new file mode 100644 index 0000000000..cdfade07d3 --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/WotRegistryEventIntegrationTests.cs @@ -0,0 +1,479 @@ +/* ======================================================================== + * 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.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.Tests; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.WotCon; +using Quickstarts.ReferenceServer; +using WotConModel = Opc.Ua.WotCon; + +#nullable enable + +namespace Opc.Ua.Server.Tests.RuntimeNodeSet +{ + /// + /// End-to-end tests that a real subscription with a real + /// receives the generated WoT V2 event types through + /// the running server's notifier chain, and that every typed event field + /// populated by from the coordinator's + /// event arguments is delivered and resolvable via the filter's + /// select clauses. + /// + [TestFixture] + [Category("RuntimeNodeSet")] + [Category("WotCon")] + [Category("Server")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public sealed class WotRegistryEventIntegrationTests + { + private string m_pkiRoot = null!; + private ServerFixture m_fixture = null!; + private ReferenceServer m_server = null!; + private RequestHeader m_requestHeader = null!; + private SecureChannelContext m_secureChannelContext = null!; + private WotRegistryService m_registry = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public async Task SetUpAsync() + { + m_pkiRoot = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(WotRegistryEventIntegrationTests), + Guid.NewGuid().ToString("N")); + + m_fixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = Utils.UriSchemeOpcTcp, + SecurityNone = true, + AutoAccept = true + }; + m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); + + (m_requestHeader, m_secureChannelContext) = await m_server + .CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name) + .ConfigureAwait(false); + m_requestHeader.Timestamp = DateTimeUtc.Now; + + var options = new WotRegistryServerOptions + { + // Refreshes are triggered explicitly by the tests so the raised + // events are deterministic. + AutoRefresh = false, + ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = ObjectIds.WellKnownRole_Anonymous + } + }; + m_registry = new WotRegistryService(); + var host = new LifecycleWotProjectionHost(m_server.NodeManagerLifecycle); + m_coordinator = new WotMaterializationCoordinator( + m_registry, host, documentConverter: new SelectiveConverter()); + var factory = new WotRegistryNodeManagerFactory(options, m_registry, m_coordinator); + await m_server.NodeManagerLifecycle.AddAsync(factory).ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_requestHeader is not null) + { + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + } + + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + + if (m_fixture is not null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) + { + Directory.Delete(m_pkiRoot, recursive: true); + } + } + + [Test] + public async Task RefreshCompletedEvent_DeliversPopulatedSummaryFieldsThroughNotifierChain() + { + NodeId registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + // Trigger a refresh: the registry raises a RefreshCompleted event with + // the request id, the committed generation and the refresh summary. + const string RequestId = "req-42"; + WotRefreshResult result = await m_coordinator + .RefreshAsync(new WotRefreshRequest { RequestId = RequestId }) + .ConfigureAwait(false); + + NodeId refreshCompletedType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTRefreshCompletedEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == refreshCompletedType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The RefreshCompleted event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.RequestId]), Is.EqualTo(RequestId), + "RequestId must be populated from the materialization event arguments."); + Assert.That(AsUInt32(fields[Field.Generation]), Is.EqualTo(result.NewGeneration), + "The event's Generation must match the committed refresh generation."); + Assert.That( + fields[Field.Summary].TryGetValue(out ExtensionObject summaryEo), Is.True, + "The refresh Summary structure field must be populated."); + Assert.That(summaryEo.TryGetValue(out WoTRefreshSummaryDataType? summary), Is.True); + Assert.That(summary!.RequestId, Is.EqualTo(RequestId), + "The Summary must carry the originating refresh request id."); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + [Test] + public async Task ResourceEvent_DeliversPopulatedIdentityFieldsThroughNotifierChain() + { + NodeId registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "sensor", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SelectiveConverter.ValidTd("sensor") + }).ConfigureAwait(false); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + NodeId resourceType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTResourceEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == resourceType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The resource activation event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.ResourceId]), Is.EqualTo("sensor")); + Assert.That(AsString(fields[Field.Xid]), Does.Contain("sensor")); + Assert.That( + fields[Field.DocumentKind].TryGetValue(out WoTDocumentKindEnum kind), Is.True, + "DocumentKind must be populated from the resource kind."); + Assert.That(kind, Is.EqualTo(WoTDocumentKindEnum.ThingDescription)); + Assert.That( + fields[Field.Outcome].TryGetValue(out WoTOutcomeEnum _), Is.True, + "Outcome must be populated for a resource lifecycle event."); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + [Test] + public async Task ValidationFailureEvent_DeliversValidationOutcomeThroughNotifierChain() + { + NodeId registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, m_server.CurrentInstance.NamespaceUris); + + // The selective converter fails conversion for ids containing 'bad', + // which the coordinator surfaces as a validation failure event. + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "bad-thing", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SelectiveConverter.ValidTd("bad-thing") + }).ConfigureAwait(false); + + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateEventSubscriptionAsync(services, registryNodeId) + .ConfigureAwait(false); + + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + NodeId validationFailureType = ExpandedNodeId.ToNodeId( + WotConModel.ObjectTypeIds.WoTValidationFailureEventType, m_server.CurrentInstance.NamespaceUris); + + EventFieldList? evt = await CollectEventAsync( + services, subscriptionId, + efl => EventTypeOf(efl) == validationFailureType).ConfigureAwait(false); + + Assert.That(evt, Is.Not.Null, + "The validation failure event must be delivered through the notifier chain."); + ArrayOf fields = evt!.EventFields; + Assert.That(AsString(fields[Field.ResourceId]), Is.EqualTo("bad-thing")); + Assert.That( + fields[Field.ValidationOutcome].TryGetValue(out ExtensionObject outcomeEo), Is.True, + "The ValidationOutcome structure field must be populated."); + Assert.That(outcomeEo.TryGetValue(out WoTValidationOutcomeDataType? outcome), Is.True); + Assert.That(outcome!.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // ---- event filter select-clause ordering -------------------------- + + private static class Field + { + public const int EventType = 0; + public const int Xid = 1; + public const int ResourceId = 2; + public const int VersionId = 3; + public const int DocumentKind = 4; + public const int Generation = 5; + public const int Phase = 6; + public const int Outcome = 7; + public const int ValidationOutcome = 8; + public const int LoadState = 9; + public const int FailedNodeId = 10; + public const int Reason = 11; + public const int BindingUri = 12; + public const int Summary = 13; + public const int RequestId = 14; + } + + private EventFilter BuildWotEventFilter() + { + ushort v2 = (ushort)m_server.CurrentInstance.NamespaceUris.GetIndex( + WotConModel.Namespaces.WotCon); + + SimpleAttributeOperand Wot(string name) + => new() + { + AttributeId = Attributes.Value, + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = [new QualifiedName(name, v2)] + }; + + SimpleAttributeOperand Base(string name) + => new() + { + AttributeId = Attributes.Value, + TypeDefinitionId = ObjectTypeIds.BaseEventType, + BrowsePath = [QualifiedName.From(name)] + }; + + return new EventFilter + { + SelectClauses = + [ + Base(BrowseNames.EventType), // 0 + Wot(WotConModel.BrowseNames.Xid), // 1 + Wot(WotConModel.BrowseNames.ResourceId), // 2 + Wot(WotConModel.BrowseNames.VersionId), // 3 + Wot(WotConModel.BrowseNames.DocumentKind), // 4 + Wot(WotConModel.BrowseNames.Generation), // 5 + Wot(WotConModel.BrowseNames.Phase), // 6 + Wot(WotConModel.BrowseNames.Outcome), // 7 + Wot(WotConModel.BrowseNames.ValidationOutcome), // 8 + Wot(WotConModel.BrowseNames.LoadState), // 9 + Wot(WotConModel.BrowseNames.FailedNodeId), // 10 + Wot(WotConModel.BrowseNames.Reason), // 11 + Wot(WotConModel.BrowseNames.BindingUri), // 12 + Wot(WotConModel.BrowseNames.Summary), // 13 + Wot(WotConModel.BrowseNames.RequestId) // 14 + ], + WhereClause = new ContentFilter() + }; + } + + private async Task CreateEventSubscriptionAsync( + ServerTestServices services, NodeId sourceNodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscription = await services + .CreateSubscriptionAsync(requestHeader, 100, 1200, 20, 0, true, 0) + .ConfigureAwait(false); + uint subscriptionId = subscription.SubscriptionId; + + ArrayOf items = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId + { + NodeId = sourceNodeId, + AttributeId = Attributes.EventNotifier + }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = ClientHandle, + SamplingInterval = 0, + QueueSize = 100, + DiscardOldest = true, + Filter = new ExtensionObject(BuildWotEventFilter()) + } + } + ]; + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse created = await services + .CreateMonitoredItemsAsync( + requestHeader, subscriptionId, TimestampsToReturn.Neither, items) + .ConfigureAwait(false); + Assert.That(created.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good), + "The event monitored item must be created on the WoTRegistry notifier."); + return subscriptionId; + } + + private async Task CollectEventAsync( + ServerTestServices services, uint subscriptionId, Func predicate) + { + ArrayOf acks = default; + for (int attempt = 0; attempt < 40; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acks, timeoutCts.Token).ConfigureAwait(false); + acks = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, SequenceNumber = sequenceNumber + }); + + if (response.NotificationMessage is { } message) + { + ArrayOf notifications = message.NotificationData; + for (int n = 0; n < notifications.Count; n++) + { + if (!notifications[n].TryGetValue(out EventNotificationList? events)) + { + continue; + } + for (int i = 0; i < events.Events.Count; i++) + { + EventFieldList efl = events.Events[i]; + if (efl.ClientHandle == ClientHandle && predicate(efl)) + { + return efl; + } + } + } + } + } + return null; + } + + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf ids = [subscriptionId]; + await services.DeleteSubscriptionsAsync(requestHeader, ids).ConfigureAwait(false); + } + + private static NodeId EventTypeOf(EventFieldList efl) + => efl.EventFields[Field.EventType].TryGetValue(out NodeId n) ? n : NodeId.Null; + + private static string AsString(Variant variant) + => variant.TryGetValue(out string s) ? s : string.Empty; + + private static uint AsUInt32(Variant variant) + => variant.TryGetValue(out uint u) ? u : 0u; + + private const uint ClientHandle = 77; + + /// + /// A converter that emits a minimal valid projection for a resource, but + /// fails conversion for any resource id containing "bad" so a validation + /// failure event can be exercised deterministically. + /// + private sealed class SelectiveConverter : IWotDocumentConverter + { + private const string ModelUri = "urn:wot:events:model"; + + public static byte[] ValidTd(string id) + => Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"urn:" + id + "\",\"title\":\"" + id + "\"}"); + + public WotConversionOutput Convert( + WotResource resource, ReadOnlyMemory content, WotRegistrySnapshot snapshot) + { + if (resource.ResourceId.Contains("bad", StringComparison.Ordinal)) + { + return WotConversionOutput.Failure( + $"Injected conversion failure for '{resource.ResourceId}'."); + } + + string ns = ModelUri + "/" + resource.ResourceId; + string xml = $""" + + + {ns} + + + Root + + i=58 + i=85 + + + + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + UANodeSet nodeSet = UANodeSet.Read(stream)!; + return WotConversionOutput.Success(nodeSet); + } + } + } +} diff --git a/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs b/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs new file mode 100644 index 0000000000..e495dc1d0f --- /dev/null +++ b/tests/Opc.Ua.Server.Tests/RuntimeNodeSet/WotRegistryLifecycleTests.cs @@ -0,0 +1,883 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Server.RuntimeNodeSet; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.Tests; +using Opc.Ua.WotCon.Server; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; +using Opc.Ua.WotCon; +using Quickstarts.ReferenceServer; +using WotConModel = Opc.Ua.WotCon; + +namespace Opc.Ua.Server.Tests.RuntimeNodeSet +{ + /// + /// End-to-end lifecycle test for the WoT Connectivity V2 registry hosted on a + /// real running . It registers a Thing + /// Description, materializes it as a shadow-reloadable runtime projection, + /// creates a real subscription and monitored item on the projected value, + /// registers a compatible new version, refreshes into a new generation, and + /// verifies that new Read/Browse observe the new generation while the existing + /// monitored item is kept alive on the retained generation until the + /// subscription is deleted and the retired projection is cleaned up. + /// + [TestFixture] + [Category("NodeManagerLifecycle")] + [Category("RuntimeNodeSet")] + [Category("WotCon")] + [Category("Server")] + [SetCulture("en-us")] + [SetUICulture("en-us")] + [NonParallelizable] + public sealed class WotRegistryLifecycleTests + { + private const double kMaxAge = 10000; + private const string kModelNamespaceUri = "urn:wot:e2e:sensor"; + private const uint kRootNodeId = 5000; + private const uint kValueNodeId = 5001; + private const uint kGenChildBaseNodeId = 5100; + private const string kValueBrowseName = "Value"; + + private string m_pkiRoot = null!; + private ServerFixture m_fixture = null!; + private ReferenceServer m_server = null!; + private RequestHeader m_requestHeader = null!; + private SecureChannelContext m_secureChannelContext = null!; + private ILogger m_logger = null!; + + private WotRegistryService m_registry = null!; + private WotMaterializationCoordinator m_coordinator = null!; + private NodeManagerRegistration m_registryRegistration = null!; + private WotRegistryServerOptions m_options = null!; + + [SetUp] + public async Task SetUpAsync() + { + m_pkiRoot = Path.Combine( + TestContext.CurrentContext.WorkDirectory, + nameof(WotRegistryLifecycleTests), + Guid.NewGuid().ToString("N")); + + m_fixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = Utils.UriSchemeOpcTcp, + SecurityNone = true, + AutoAccept = true + }; + + m_server = await m_fixture.StartAsync(m_pkiRoot).ConfigureAwait(false); + m_logger = NUnitTelemetryContext.Create().CreateLogger(); + + (m_requestHeader, m_secureChannelContext) = await m_server + .CreateAndActivateSessionAsync(TestContext.CurrentContext.Test.Name) + .ConfigureAwait(false); + m_requestHeader.Timestamp = DateTimeUtc.Now; + + // Host the WoT registry NodeManager on the running server with a + // deterministic converter so the projected value node is predictable. + m_options = new WotRegistryServerOptions + { + AutoRefresh = false, + ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = ObjectIds.WellKnownRole_Anonymous + } + }; + m_registry = new WotRegistryService(); + var host = new LifecycleWotProjectionHost(m_server.NodeManagerLifecycle); + m_coordinator = new WotMaterializationCoordinator( + m_registry, host, documentConverter: new SensorConverter()); + var factory = new WotRegistryNodeManagerFactory(m_options, m_registry, m_coordinator); + m_registryRegistration = await m_server.NodeManagerLifecycle + .AddAsync(factory).ConfigureAwait(false); + } + + [TearDown] + public async Task TearDownAsync() + { + if (m_requestHeader is not null) + { + m_requestHeader.Timestamp = DateTimeUtc.Now; + await m_server + .CloseSessionAsync(m_secureChannelContext, m_requestHeader, true, RequestLifetime.None) + .ConfigureAwait(false); + } + + m_coordinator?.Dispose(); + m_registry?.Dispose(); + m_server?.Dispose(); + + if (m_fixture is not null) + { + await m_fixture.StopAsync().ConfigureAwait(false); + } + + if (!string.IsNullOrEmpty(m_pkiRoot) && Directory.Exists(m_pkiRoot)) + { + Directory.Delete(m_pkiRoot, recursive: true); + } + } + + [Test] + public async Task RegisterMaterializeSubscribeRefreshAndRetireAsync() + { + IServerInternal server = m_server.CurrentInstance; + + // 1. Register and materialize the first generation of the Thing Description. + await UpsertSensorAsync("sensor", generation: 1).ConfigureAwait(false); + WotRefreshResult first = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(first.Results.Any(r => r.LoadState == WoTLoadStateEnum.Active), Is.True, + "The registered Thing Description must materialize into an active projection."); + + // The browseable registry projection exposes the group and resource. + await AssertRegistryProjectionAsync(server).ConfigureAwait(false); + + ushort ns = (ushort)server.NamespaceUris.GetIndex(kModelNamespaceUri); + Assert.That(ns, Is.GreaterThan(0), "The projected model namespace must be registered."); + var valueNodeId = new NodeId(kValueNodeId, ns); + var rootNodeId = new NodeId(kRootNodeId, ns); + var gen1ChildNodeId = new NodeId(kGenChildBaseNodeId + 1u, ns); + var gen2ChildNodeId = new NodeId(kGenChildBaseNodeId + 2u, ns); + + DataValue value1 = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value1.StatusCode, Is.EqualTo(StatusCodes.Good), + "The projected value node must be materialized and readable."); + DataValue gen1Read = await ReadValueAsync(gen1ChildNodeId).ConfigureAwait(false); + Assert.That(gen1Read.StatusCode, Is.EqualTo(StatusCodes.Good), + "The first generation's node must be present after materialization."); + + // 2. Create a subscription and monitored item on the projected value. + var services = new ServerTestServices(m_server, m_secureChannelContext); + uint subscriptionId = await CreateSubscriptionWithMonitoredItemAsync(services, valueNodeId) + .ConfigureAwait(false); + ArrayOf acks = default; + (DataValue? initial, acks) = await PublishForDataChangeAsync( + services, subscriptionId, acks, clientHandle: 1).ConfigureAwait(false); + Assert.That(initial, Is.Not.Null, + "The monitored item must deliver an initial data-change notification."); + + try + { + // 3. Register a compatible new version and refresh into a new generation. + await UpsertSensorAsync("sensor", generation: 2).ConfigureAwait(false); + WotRefreshResult second = await m_coordinator + .RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + Assert.That(second.NewGeneration, Is.GreaterThan(first.NewGeneration), + "A compatible new version must advance the refresh generation."); + + // 4. New Read/Browse observe the new generation: new service requests + // route to the replacement generation (which exposes the Gen2 node and + // no longer the Gen1 node), while the value node persists across both. + DataValue value2 = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That(value2.StatusCode, Is.EqualTo(StatusCodes.Good)); + DataValue gen2Read = await ReadValueAsync(gen2ChildNodeId).ConfigureAwait(false); + Assert.That(gen2Read.StatusCode, Is.EqualTo(StatusCodes.Good), + "A Read after the refresh must observe the new generation's node."); + DataValue gen1AfterSwitch = await ReadValueAsync(gen1ChildNodeId).ConfigureAwait(false); + Assert.That( + gen1AfterSwitch.StatusCode.Code, + Is.EqualTo(StatusCodes.BadNodeIdUnknown).Or.EqualTo(StatusCodes.BadNodeIdInvalid), + "New requests must no longer resolve the retired generation's node."); + + BrowseResponse rootBrowse = await BrowseAsync(rootNodeId).ConfigureAwait(false); + ArrayOf references = rootBrowse.Results[0].References; + Assert.That( + references.Contains(r => r.BrowseName.Equals( + new QualifiedName(GenChildBrowseName(2), ns))), + Is.True, "Browse must observe the new generation's child."); + Assert.That( + references.Contains(r => r.BrowseName.Equals( + new QualifiedName(GenChildBrowseName(1), ns))), + Is.False, "Browse must no longer observe the retired generation's child."); + + // 5. The existing monitored item remains alive across the switch: the + // subscription still services publishes without invalidating the item. + (_, acks) = await PublishKeepAliveAsync(services, subscriptionId, acks) + .ConfigureAwait(false); + } + finally + { + // 6. Delete the subscription, releasing the monitored item. + await DeleteSubscriptionAsync(services, subscriptionId).ConfigureAwait(false); + } + + // 7. Remove the resource and refresh: the retired projection is cleaned up. + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "sensor") + .ConfigureAwait(false); + await m_coordinator.RefreshAsync(new WotRefreshRequest()).ConfigureAwait(false); + + DataValue removed = await ReadValueAsync(valueNodeId).ConfigureAwait(false); + Assert.That( + removed.StatusCode.Code, + Is.EqualTo(StatusCodes.BadNodeIdUnknown).Or.EqualTo(StatusCodes.BadNodeIdInvalid), + "The retired projection's nodes must be cleaned up after the resource is removed."); + } + + private async Task AssertRegistryProjectionAsync(IServerInternal server) + { + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + ushort v2Ns = (ushort)server.NamespaceUris.GetIndex(WotConModel.Namespaces.WotCon); + var groupNodeId = new NodeId( + "WoTRegistry/groups/" + WotRegistryGroups.ThingDescriptions, v2Ns); + var resourceNodeId = new NodeId( + $"WoTRegistry/groups/{WotRegistryGroups.ThingDescriptions}/resources/sensor", v2Ns); + + bool groupVisible = await WaitForConditionAsync(async () => + { + BrowseResponse browse = await BrowseAsync(registryNodeId).ConfigureAwait(false); + return browse.Results[0].References.Contains(r => + ExpandedNodeId.ToNodeId(r.NodeId, server.NamespaceUris) == groupNodeId); + }).ConfigureAwait(false); + Assert.That(groupVisible, Is.True, "WoTRegistry must expose the Thing Description group."); + + bool resourceVisible = await WaitForConditionAsync(async () => + { + BrowseResponse browse = await BrowseAsync(groupNodeId).ConfigureAwait(false); + return browse.Results[0].References.Contains(r => + ExpandedNodeId.ToNodeId(r.NodeId, server.NamespaceUris) == resourceNodeId); + }).ConfigureAwait(false); + Assert.That(resourceVisible, Is.True, + "The group must expose the registered resource document."); + } + + [Test] + public async Task CrudMethodsAndFileUploadDriveTheRegistryAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + + // 1. CreateGroup via the xRegistry CreateGroup Method on WoTRegistry. + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, createGroupId, new Variant("sensors")).ConfigureAwait(false); + Assert.That(createGroup.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindGroup("sensors"), Is.Not.Null); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + // 2. GetOrCreateResource with RequestFileOpen returns a write FileHandle. + NodeId getOrCreateResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, getOrCreateResourceId, + new Variant("thing1"), new Variant(string.Empty), new Variant(true)) + .ConfigureAwait(false); + Assert.That(createResource.StatusCode, Is.EqualTo(StatusCodes.Good)); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + uint fileHandle = createResource.OutputArguments[2].GetUInt32(); + Assert.That(fileHandle, Is.Not.Zero, + "RequestFileOpen must return a non-zero write FileHandle."); + + byte[] td = Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"urn:thing1\",\"title\":\"thing1\"}"); + + // 3. Write the document body through the inherited FileType and commit on Close. + NodeId writeId = await FindChildAsync(resourceNodeId, "Write").ConfigureAwait(false); + NodeId closeId = await FindChildAsync(resourceNodeId, "Close").ConfigureAwait(false); + CallMethodResult write = await CallAsync( + resourceNodeId, writeId, + new Variant(fileHandle), new Variant(ByteString.From(td))).ConfigureAwait(false); + Assert.That(write.StatusCode, Is.EqualTo(StatusCodes.Good)); + + CallMethodResult close = await CallAsync( + resourceNodeId, closeId, new Variant(fileHandle)).ConfigureAwait(false); + Assert.That(close.StatusCode, Is.EqualTo(StatusCodes.Good)); + + WotResource stored = m_registry.Current.FindResource("sensors", "thing1"); + Assert.That(stored?.DefaultVersion, Is.Not.Null, + "Closing the write handle must commit the buffered document as a version."); + + // 4. Validate the stored document. + NodeId validateId = await FindChildAsync(resourceNodeId, "Validate").ConfigureAwait(false); + CallMethodResult validate = await CallAsync(resourceNodeId, validateId) + .ConfigureAwait(false); + Assert.That(validate.StatusCode, Is.EqualTo(StatusCodes.Good)); + object outcomeBoxed = validate.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + Assert.That(outcomeBoxed, Is.InstanceOf()); + Assert.That(((ExtensionObject)outcomeBoxed).TryGetValue( + out WoTValidationOutcomeDataType outcome), Is.True); + Assert.That(outcome.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Success)); + + // 5. SetEnabled(false) through the document Method. + NodeId setEnabledId = await FindChildAsync(resourceNodeId, "SetEnabled") + .ConfigureAwait(false); + CallMethodResult setEnabled = await CallAsync( + resourceNodeId, setEnabledId, + new Variant(false), new Variant(0u)).ConfigureAwait(false); + Assert.That(setEnabled.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindResource("sensors", "thing1")!.Enabled, Is.False); + + // 6. Delete the resource through the xRegistry Delete Method. + NodeId deleteId = await FindChildAsync(resourceNodeId, "Delete").ConfigureAwait(false); + CallMethodResult delete = await CallAsync( + resourceNodeId, deleteId, new Variant(0u)).ConfigureAwait(false); + Assert.That(delete.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(m_registry.Current.FindResource("sensors", "thing1"), Is.Null); + } + + [Test] + public async Task FileWriteRequiresConfiguredSecureChannelWhileReadMayUseNoneAsync() + { + IServerInternal server = m_server.CurrentInstance; + NodeId registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, + server.NamespaceUris); + + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, + createGroupId, + new Variant("secure-files")).ConfigureAwait(false); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId createResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, + createResourceId, + new Variant("thing1"), + new Variant(string.Empty), + new Variant(true)).ConfigureAwait(false); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + uint writeHandle = createResource.OutputArguments[2].GetUInt32(); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + AllowAnonymous = true, + RequiredRoleId = ObjectIds.WellKnownRole_Anonymous + }; + + NodeId writeId = await FindChildAsync(resourceNodeId, "Write").ConfigureAwait(false); + CallMethodResult write = await CallAsync( + resourceNodeId, + writeId, + new Variant(writeHandle), + new Variant(ByteString.From(new byte[] { 1, 2, 3 }))).ConfigureAwait(false); + Assert.That(write.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + NodeId closeId = await FindChildAsync(resourceNodeId, "Close").ConfigureAwait(false); + CallMethodResult writeClose = await CallAsync( + resourceNodeId, + closeId, + new Variant(writeHandle)).ConfigureAwait(false); + Assert.That(writeClose.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + MinimumSecurityMode = MessageSecurityMode.None, + AllowAnonymous = true, + RequiredRoleId = ObjectIds.WellKnownRole_Anonymous + }; + NodeId openId = await FindChildAsync(resourceNodeId, "Open").ConfigureAwait(false); + CallMethodResult authorizedWriteOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)6)).ConfigureAwait(false); + Assert.That(authorizedWriteOpen.StatusCode, Is.EqualTo(StatusCodes.Good), + "A denied close must discard and release the prior writer handle."); + uint authorizedWriteHandle = authorizedWriteOpen.OutputArguments[0].GetUInt32(); + CallMethodResult authorizedWriteClose = await CallAsync( + resourceNodeId, + closeId, + new Variant(authorizedWriteHandle)).ConfigureAwait(false); + Assert.That(authorizedWriteClose.StatusCode, Is.EqualTo(StatusCodes.Good)); + + m_options.ManagementAccess = new WotManagementAccessPolicy + { + AllowAnonymous = true, + RequiredRoleId = ObjectIds.WellKnownRole_Anonymous + }; + CallMethodResult writeOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)6)).ConfigureAwait(false); + Assert.That(writeOpen.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied)); + + CallMethodResult readOpen = await CallAsync( + resourceNodeId, + openId, + new Variant((byte)1)).ConfigureAwait(false); + Assert.That(readOpen.StatusCode, Is.EqualTo(StatusCodes.Good)); + uint readHandle = readOpen.OutputArguments[0].GetUInt32(); + + CallMethodResult close = await CallAsync( + resourceNodeId, + closeId, + new Variant(readHandle)).ConfigureAwait(false); + Assert.That(close.StatusCode, Is.EqualTo(StatusCodes.Good)); + } + + [Test] + public async Task LabelsAddUpdateRemoveViaRealNodeManagerAsync() + { + IServerInternal server = m_server.CurrentInstance; + var registryNodeId = ExpandedNodeId.ToNodeId( + WotConModel.ObjectIds.WoTRegistry, server.NamespaceUris); + + // ---- registry-level Labels ------------------------------------ + NodeId registryLabelsId = await FindChildAsync(registryNodeId, "Labels") + .ConfigureAwait(false); + NodeId registryAddId = await FindChildAsync(registryLabelsId, "AddAttribute") + .ConfigureAwait(false); + NodeId registryRemoveId = await FindChildAsync(registryLabelsId, "RemoveAttribute") + .ConfigureAwait(false); + + CallMethodResult addRegistry = await CallAsync( + registryLabelsId, registryAddId, + new Variant("environment"), new Variant("production"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addRegistry.StatusCode, Is.EqualTo(StatusCodes.Good)); + + NodeId envNodeId = await FindChildAsync(registryLabelsId, "environment") + .ConfigureAwait(false); + DataValue envValue = await ReadValueAsync(envNodeId).ConfigureAwait(false); + Assert.That(envValue.StatusCode, Is.EqualTo(StatusCodes.Good)); + Assert.That(envValue.GetValue(null), Is.EqualTo("production")); + + // ---- group-level Labels ---------------------------------------- + NodeId createGroupId = await FindChildAsync(registryNodeId, "CreateGroup") + .ConfigureAwait(false); + CallMethodResult createGroup = await CallAsync( + registryNodeId, createGroupId, new Variant("labelgroup")).ConfigureAwait(false); + Assert.That(createGroup.StatusCode, Is.EqualTo(StatusCodes.Good)); + var groupNodeId = (NodeId)createGroup.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId groupLabelsId = await FindChildAsync(groupNodeId, "Labels").ConfigureAwait(false); + NodeId groupAddId = await FindChildAsync(groupLabelsId, "AddAttribute") + .ConfigureAwait(false); + + CallMethodResult addGroupLabel = await CallAsync( + groupLabelsId, groupAddId, + new Variant("owner"), new Variant("team-iot"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addGroupLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + NodeId ownerNodeId = await FindChildAsync(groupLabelsId, "owner").ConfigureAwait(false); + Assert.That( + (await ReadValueAsync(ownerNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("team-iot")); + + // Epoch mismatch is rejected with Bad_InvalidState and makes no change. + NodeId groupEpochId = await FindChildAsync(groupNodeId, "Epoch").ConfigureAwait(false); + uint groupEpoch = (await ReadValueAsync(groupEpochId).ConfigureAwait(false)) + .GetValue(0); + CallMethodResult mismatchedGroup = await CallAsync( + groupLabelsId, groupAddId, + new Variant("owner"), new Variant("team-other"), new Variant(groupEpoch + 999)) + .ConfigureAwait(false); + Assert.That(mismatchedGroup.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + Assert.That( + (await ReadValueAsync(ownerNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("team-iot"), "A rejected epoch mismatch must not change the label value."); + + // A key colliding with a fixed Labels container member is rejected. + CallMethodResult collision = await CallAsync( + groupLabelsId, groupAddId, + new Variant("AddAttribute"), new Variant("x"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(collision.StatusCode, Is.EqualTo(StatusCodes.BadBrowseNameDuplicated)); + + // A key with a path-separator character is rejected. + CallMethodResult invalidKey = await CallAsync( + groupLabelsId, groupAddId, + new Variant("a/b"), new Variant("x"), new Variant(0u)).ConfigureAwait(false); + Assert.That(invalidKey.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + + // ---- resource-level Labels -------------------------------------- + NodeId getOrCreateResourceId = await FindChildAsync(groupNodeId, "GetOrCreateResource") + .ConfigureAwait(false); + CallMethodResult createResource = await CallAsync( + groupNodeId, getOrCreateResourceId, + new Variant("thing1"), new Variant(string.Empty), new Variant(false)) + .ConfigureAwait(false); + Assert.That(createResource.StatusCode, Is.EqualTo(StatusCodes.Good)); + var resourceNodeId = (NodeId)createResource.OutputArguments[0] + .AsBoxedObject(Variant.BoxingBehavior.Legacy); + + NodeId resourceLabelsId = await FindChildAsync(resourceNodeId, "Labels") + .ConfigureAwait(false); + NodeId resourceAddId = await FindChildAsync(resourceLabelsId, "AddAttribute") + .ConfigureAwait(false); + NodeId resourceRemoveId = await FindChildAsync(resourceLabelsId, "RemoveAttribute") + .ConfigureAwait(false); + + CallMethodResult addResourceLabel = await CallAsync( + resourceLabelsId, resourceAddId, + new Variant("site"), new Variant("seattle"), new Variant(0u)) + .ConfigureAwait(false); + Assert.That(addResourceLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + NodeId siteNodeId = await FindChildAsync(resourceLabelsId, "site").ConfigureAwait(false); + Assert.That( + (await ReadValueAsync(siteNodeId).ConfigureAwait(false)).GetValue(null), + Is.EqualTo("seattle")); + + // Remove the resource label; it must disappear from Browse. + CallMethodResult removeResourceLabel = await CallAsync( + resourceLabelsId, resourceRemoveId, + new Variant("site"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeResourceLabel.StatusCode, Is.EqualTo(StatusCodes.Good)); + BrowseResponse afterRemove = await BrowseAsync(resourceLabelsId).ConfigureAwait(false); + Assert.That( + afterRemove.Results[0].References.Contains( + r => string.Equals(r.BrowseName.Name, "site", StringComparison.Ordinal)), + Is.False); + + // Removing an unknown label fails with a precise StatusCode. + CallMethodResult removeUnknown = await CallAsync( + resourceLabelsId, resourceRemoveId, + new Variant("missing"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeUnknown.StatusCode, Is.EqualTo(StatusCodes.BadNodeIdUnknown)); + + // ---- registry-level remove -------------------------------------- + CallMethodResult removeRegistry = await CallAsync( + registryLabelsId, registryRemoveId, + new Variant("environment"), new Variant(0u)).ConfigureAwait(false); + Assert.That(removeRegistry.StatusCode, Is.EqualTo(StatusCodes.Good)); + BrowseResponse registryAfterRemove = await BrowseAsync(registryLabelsId) + .ConfigureAwait(false); + Assert.That( + registryAfterRemove.Results[0].References.Contains( + r => string.Equals(r.BrowseName.Name, "environment", StringComparison.Ordinal)), + Is.False); + } + + private async Task FindChildAsync(NodeId parent, string browseName) + { + BrowseResponse browse = await BrowseAsync(parent).ConfigureAwait(false); + foreach (ReferenceDescription reference in browse.Results[0].References) + { + if (string.Equals(reference.BrowseName.Name, browseName, StringComparison.Ordinal)) + { + return ExpandedNodeId.ToNodeId( + reference.NodeId, m_server.CurrentInstance.NamespaceUris); + } + } + Assert.Fail($"Child '{browseName}' was not found under {parent}."); + return NodeId.Null; + } + + private async Task CallAsync( + NodeId objectId, NodeId methodId, params Variant[] inputs) + { + ArrayOf methods = + [ + new CallMethodRequest + { + ObjectId = objectId, + MethodId = methodId, + InputArguments = inputs + } + ]; + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CallResponse response = await m_server + .CallAsync(m_secureChannelContext, requestHeader, methods, RequestLifetime.None) + .ConfigureAwait(false); + return response.Results[0]; + } + + private static async Task WaitForConditionAsync(Func> condition) + { + for (int attempt = 0; attempt < 50; attempt++) + { + if (await condition().ConfigureAwait(false)) + { + return true; + } + await Task.Delay(100).ConfigureAwait(false); + } + return false; + } + + private async Task UpsertSensorAsync(string resourceId, int generation) + { + await m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = SensorConverter.BuildContent(generation) + }).ConfigureAwait(false); + } + + private static string GenChildBrowseName(int generation) + => "Gen" + generation.ToString(CultureInfo.InvariantCulture); + + // ---- deterministic converter ------------------------------------- + + /// + /// Emits a NodeSet2 whose model namespace is fixed and whose value node + /// carries the generation number parsed from the document, plus a + /// generation-specific child so a Browse can distinguish generations. + /// + private sealed class SensorConverter : IWotDocumentConverter + { + public static byte[] BuildContent(int generation) + => Encoding.UTF8.GetBytes( + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"@type\":\"uav:object\",\"id\":\"" + kModelNamespaceUri + "\"," + + "\"title\":\"sensor\",\"gen\":" + + generation.ToString(CultureInfo.InvariantCulture) + "}"); + + public WotConversionOutput Convert( + WotResource resource, ReadOnlyMemory content, WotRegistrySnapshot snapshot) + { + int generation = ParseGeneration(content.Span); + uint childId = kGenChildBaseNodeId + (uint)generation; + string childName = "Gen" + generation.ToString(CultureInfo.InvariantCulture); + string xml = $""" + + + + {kModelNamespaceUri} + + + + + + Sensor + + i=58 + ns=1;i={kValueNodeId} + ns=1;i={childId} + i=85 + + + + {kValueBrowseName} + + i=63 + ns=1;i={kRootNodeId} + + {generation} + + + {childName} + + i=63 + ns=1;i={kRootNodeId} + + {generation} + + + """; + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(xml)); + UANodeSet nodeSet = UANodeSet.Read(stream)!; + return WotConversionOutput.Success(nodeSet); + } + + private static int ParseGeneration(ReadOnlySpan content) + { + try + { + var reader = new System.Text.Json.Utf8JsonReader(content); + while (reader.Read()) + { + if (reader.TokenType == System.Text.Json.JsonTokenType.PropertyName && + reader.GetString() == "gen" && reader.Read()) + { + return reader.GetInt32(); + } + } + } + catch (System.Text.Json.JsonException) + { + // fall through + } + return 1; + } + } + + // ---- client helpers (adapted from RuntimeNodeSetLifecycleTests) ---- + + private async Task ReadValueAsync(NodeId nodeId, uint attributeId = Attributes.Value) + { + ArrayOf readIds = + [new ReadValueId { NodeId = nodeId, AttributeId = attributeId }]; + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ReadResponse response = await m_server.ReadAsync( + m_secureChannelContext, requestHeader, kMaxAge, + TimestampsToReturn.Neither, readIds, RequestLifetime.None).ConfigureAwait(false); + return response.Results[0]; + } + + private async Task BrowseAsync(NodeId nodeId) + { + var services = new ServerTestServices(m_server, m_secureChannelContext); + var template = new BrowseDescription + { + BrowseDirection = BrowseDirection.Forward, + ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, + IncludeSubtypes = true, + NodeClassMask = 0, + ResultMask = (uint)BrowseResultMask.All + }; + ArrayOf nodesToBrowse = + ServerFixtureUtils.CreateBrowseDescriptionCollectionFromNodeId([nodeId], template); + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + return await services + .BrowseAsync(requestHeader, view: null, requestedMaxReferencesPerNode: 0, nodesToBrowse) + .ConfigureAwait(false); + } + + private async Task CreateSubscriptionWithMonitoredItemAsync( + ServerTestServices services, NodeId nodeId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateSubscriptionResponse subscriptionResponse = await services + .CreateSubscriptionAsync(requestHeader, 100, 100, 10, 0, true, 0).ConfigureAwait(false); + uint subscriptionId = subscriptionResponse.SubscriptionId; + + ArrayOf monitoredItems = + [ + new MonitoredItemCreateRequest + { + ItemToMonitor = new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }, + MonitoringMode = MonitoringMode.Reporting, + RequestedParameters = new MonitoringParameters + { + ClientHandle = 1, SamplingInterval = 0, QueueSize = 1, DiscardOldest = true + } + } + ]; + requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + CreateMonitoredItemsResponse createItemsResponse = await services + .CreateMonitoredItemsAsync( + requestHeader, subscriptionId, TimestampsToReturn.Both, monitoredItems) + .ConfigureAwait(false); + Assert.That(createItemsResponse.Results[0].StatusCode, Is.EqualTo(StatusCodes.Good)); + return subscriptionId; + } + + private async Task DeleteSubscriptionAsync(ServerTestServices services, uint subscriptionId) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + ArrayOf subscriptionIds = [subscriptionId]; + DeleteSubscriptionsResponse response = await services + .DeleteSubscriptionsAsync(requestHeader, subscriptionIds).ConfigureAwait(false); + Assert.That(response.Results[0], Is.EqualTo(StatusCodes.Good)); + } + + private async Task<(DataValue? Value, ArrayOf Acknowledgements)> + PublishForDataChangeAsync( + ServerTestServices services, uint subscriptionId, + ArrayOf acknowledgements, uint clientHandle) + { + const int MaxPublishAttempts = 20; + DataValue? value = null; + for (int attempt = 0; attempt < MaxPublishAttempts && value is null; attempt++) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token).ConfigureAwait(false); + acknowledgements = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, SequenceNumber = sequenceNumber + }); + if (response.NotificationMessage is { } message) + { + foreach (ExtensionObject notificationData in message.NotificationData) + { + if (notificationData.TryGetValue(out DataChangeNotification dcn)) + { + foreach (MonitoredItemNotification item in dcn.MonitoredItems) + { + if (item.ClientHandle == clientHandle) + { + value = item.Value; + } + } + } + } + } + } + Assert.That(value, Is.Not.Null, + $"No data-change notification for client handle {clientHandle} arrived."); + return (value, acknowledgements); + } + + private async Task<(bool Alive, ArrayOf Acknowledgements)> + PublishKeepAliveAsync( + ServerTestServices services, uint subscriptionId, + ArrayOf acknowledgements) + { + RequestHeader requestHeader = m_requestHeader; + requestHeader.Timestamp = DateTimeUtc.Now; + using var timeoutCts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + PublishResponse response = await services + .PublishAsync(requestHeader, acknowledgements, timeoutCts.Token).ConfigureAwait(false); + Assert.That(response.SubscriptionId, Is.EqualTo(subscriptionId), + "The subscription and its monitored item must stay alive across the shadow reload."); + ArrayOf acks = response.AvailableSequenceNumbers.ToArrayOf( + sequenceNumber => new SubscriptionAcknowledgement + { + SubscriptionId = subscriptionId, SequenceNumber = sequenceNumber + }); + return (true, acks); + } + } +} diff --git a/tests/Opc.Ua.SourceGeneration.Core.Tests/CompilerUtils.cs b/tests/Opc.Ua.SourceGeneration.Core.Tests/CompilerUtils.cs index f9788d561f..77cd46bbc5 100644 --- a/tests/Opc.Ua.SourceGeneration.Core.Tests/CompilerUtils.cs +++ b/tests/Opc.Ua.SourceGeneration.Core.Tests/CompilerUtils.cs @@ -90,6 +90,14 @@ public static AdditionalText From(string resourceName) throw new FileNotFoundException("Resource not found"); } + /// + /// Creates an in-memory additional file with the supplied path and content. + /// + public static AdditionalText FromContent(string path, string content) + { + return new EmbeddedText(path, content); + } + private readonly SourceText m_text; } @@ -245,6 +253,27 @@ public static CSharpCompilation CreateCompilation( .AddReferences(DefaultReferences); } + /// + /// Runs the generator driver against the compilation and returns the + /// generator diagnostics together with the run result, without + /// asserting success. Use for tests that need to inspect diagnostics + /// produced by malformed, unsupported or otherwise rejected inputs + /// (for example a WoT model that fails to parse or convert) instead + /// of asserting a clean, fully compiling run. Named distinctly from + /// the built-in GeneratorDriver.RunGenerators API (which + /// returns a , not diagnostics) to avoid + /// ambiguity. + /// + public static (ImmutableArray Diagnostics, GeneratorDriverRunResult RunResult) + RunGeneratorsForDiagnostics(this GeneratorDriver driver, Compilation compilation) + { + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, + out Compilation _, + out ImmutableArray diagnostics); + return (diagnostics, driver.GetRunResult()); + } + /// /// Add code files to compilation /// diff --git a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs index 0a1c41bfba..a87d7c0aeb 100644 --- a/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs +++ b/tests/Opc.Ua.SourceGeneration.Tests/ModelGeneratorTests.cs @@ -30,6 +30,8 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; +using System.Globalization; +using System.IO; using System.Linq; using System.Text; using System.Xml.Linq; @@ -37,6 +39,8 @@ using Microsoft.CodeAnalysis.CSharp; using Microsoft.CodeAnalysis.CSharp.Syntax; using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; namespace Opc.Ua.SourceGeneration { @@ -137,6 +141,421 @@ public void GenerateAndCompileDemoModelNodeSetsTest( Assert.That(generatorResult.GeneratedSources, Has.Length.EqualTo(19)); } + [Test] + public void GenerateAndCompileDemoModelWotNativeProjectionTest() + { + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() + .AddCode( + new Dictionary().WithOpcUaGeneratedStack(), + LanguageVersion.CSharp13); + var options = new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorStartId"] = "1000", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" + }); + + AdditionalText nodeSetText = EmbeddedText.From("DemoModel.NodeSet2.xml"); + using var nodeSetStream = new MemoryStream( + Encoding.UTF8.GetBytes(nodeSetText.GetText()!.ToString())); + UANodeSet nodeSet = UANodeSet.Read(nodeSetStream)!; + using WotDocument wot = WotNodeSetConverter.FromNodeSet(nodeSet); + Assert.That( + wot.TryGetEnvelope(out _), + Is.False, + "Source-generation equivalence shall be proved by uav:nodes, not the envelope."); + AdditionalText wotText = EmbeddedText.FromContent( + "DemoModel.tm.json", + Encoding.UTF8.GetString(wot.Utf8Json.ToArray())); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp13)) + .AddAdditionalTexts( + [ + wotText, + EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") + ]) + .WithUpdatedAnalyzerConfigOptions(options); + + GeneratorRunResult generatorResult = GenerateAndCompile(driver, compilation); + Assert.That( + generatorResult.GeneratedSources.Any(source => + source.HintName.Contains("DemoModel", StringComparison.Ordinal)), + Is.True); + } + + [TestCase("DemoModel.tm.json")] + [TestCase("DemoModel.td.json")] + [TestCase("DemoModel.tm.jsonld")] + [TestCase("DemoModel.td.jsonld")] + public void RecognizesAllSupportedWotExtensionsTest(string fileName) + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); + + GeneratorRunResult generatorResult = RunDemoModelGenerator( + LanguageVersion.CSharp13, + EmbeddedText.FromContent(fileName, wotJson), + DefaultWotOptions()); + + Assert.That( + generatorResult.GeneratedSources.Any(source => + source.HintName.StartsWith("DemoModel.", StringComparison.Ordinal)), + Is.True, + $"'{fileName}' should be recognized as a WoT model input"); + } + + [Test] + public void PlainJsonLdWithoutOptInIsNotConsumedAsModelInputTest() + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); + + // No opt-in metadata is set for the plain .jsonld file: it must be + // treated as arbitrary JSON-LD, not as a WoT model, so the whole + // run has nothing to generate (and reports no diagnostics), + // even though the content is itself a perfectly valid WoT + // envelope document. + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + DefaultWotOptions(), + [EmbeddedText.FromContent("DemoModel.jsonld", wotJson)]); + + Assert.That(diagnostics, Is.Empty); + Assert.That(runResult.Results[0].GeneratedSources, Is.Empty); + } + + [Test] + public void PlainJsonLdOptInIsConsumedAsModelInputTest() + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); + + var options = DefaultWotOptions(); + options.TextOptions["DemoModel.jsonld"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorWot"] = "true" + }; + + GeneratorRunResult generatorResult = RunDemoModelGenerator( + LanguageVersion.CSharp13, + EmbeddedText.FromContent("DemoModel.jsonld", wotJson), + options); + + Assert.That( + generatorResult.GeneratedSources.Any(source => + source.HintName.StartsWith("DemoModel.", StringComparison.Ordinal)), + Is.True, + "a .jsonld file with ModelSourceGeneratorWot=true should be recognized as a WoT model input"); + } + + [Test] + public void NodeSetAndEnvelopeWotProduceEquivalentGeneratedOutputTest() + { + const LanguageVersion languageVersion = LanguageVersion.CSharp13; + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + + GeneratorRunResult nodeSetResult = RunDemoModelGenerator( + languageVersion, + EmbeddedText.FromContent("DemoModel.NodeSet2.xml", nodeSetXml), + DefaultWotOptions()); + GeneratorRunResult wotResult = RunDemoModelGenerator( + languageVersion, + EmbeddedText.FromContent("DemoModel.tm.json", BuildDemoModelWotEnvelopeJson(nodeSetXml)), + DefaultWotOptions()); + + string[] nodeSetHints = + [ + .. nodeSetResult.GeneratedSources + .Select(s => s.HintName) + .Where(h => h.StartsWith("DemoModel.", StringComparison.Ordinal)) + .OrderBy(h => h, StringComparer.Ordinal) + ]; + string[] wotHints = + [ + .. wotResult.GeneratedSources + .Select(s => s.HintName) + .Where(h => h.StartsWith("DemoModel.", StringComparison.Ordinal)) + .OrderBy(h => h, StringComparer.Ordinal) + ]; + Assert.That(nodeSetHints, Is.Not.Empty); + Assert.That( + wotHints, + Is.EqualTo(nodeSetHints), + "a WoT envelope input should generate the same set of hint names as the equivalent NodeSet2 input"); + + foreach (string hint in nodeSetHints) + { + string nodeSetSource = nodeSetResult.GeneratedSources + .Single(s => s.HintName == hint).SourceText.ToString(); + string wotSource = wotResult.GeneratedSources + .Single(s => s.HintName == hint).SourceText.ToString(); + Assert.That( + wotSource, + Is.EqualTo(nodeSetSource), + $"generated source for '{hint}' should be identical between the NodeSet2 and WoT-envelope inputs"); + } + } + + [Test] + public void MalformedWotJsonProducesDiagnosticWithoutGeneratorExceptionTest() + { + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + DefaultWotOptions(), + [ + EmbeddedText.FromContent("Malformed.tm.json", "{ this is not valid json"), + EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") + ]); + + Assert.That( + runResult.Results[0].Exception, + Is.Null, + "a malformed WoT document must not crash the generator"); + Assert.That( + diagnostics.Any(d => d.Id == "MODELGEN030"), + Is.True, + "a malformed WoT document should produce a MODELGEN030 diagnostic"); + // The malformed WoT input must not abort generation for the rest + // of the compilation: DI does not depend on it and should still + // be generated. + Assert.That(runResult.Results[0].GeneratedSources, Is.Not.Empty); + } + + [Test] + public void WotDocumentWithoutEnvelopeNativeMappingOrRecognizedTypeProducesDiagnosticTest() + { + const string json = """{ "title": "NotARecognizedThing" }"""; + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + DefaultWotOptions(), + [ + EmbeddedText.FromContent("Unrecognized.td.json", json), + EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") + ]); + + Assert.That(runResult.Results[0].Exception, Is.Null); + Assert.That( + diagnostics.Any(d => + d.Id == "MODELGEN031" && + d.GetMessage(CultureInfo.InvariantCulture).Contains("WOT3001", StringComparison.Ordinal)), + Is.True, + "a document that is neither a recognized Thing Model/Description nor carries a " + + "preservation envelope or native mapping should report WotDiagnosticCode.NoConvertibleContent (WOT3001)"); + } + + [Test] + public void DigestMismatchedWotEnvelopeProducesDiagnosticWithoutGeneratorExceptionTest() + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string tamperedJson = TamperEnvelopeDigestData( + BuildDemoModelWotEnvelopeJson(nodeSetXml, includeEnvelope: true)); + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + DefaultWotOptions(), + [ + EmbeddedText.FromContent("Tampered.tm.json", tamperedJson), + EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") + ]); + + Assert.That(runResult.Results[0].Exception, Is.Null); + Assert.That( + diagnostics.Any(d => + d.Id == "MODELGEN031" && + d.GetMessage(CultureInfo.InvariantCulture).Contains("WOT2006", StringComparison.Ordinal)), + Is.True, + "a tampered envelope digest should report WotDiagnosticCode.DigestMismatch (WOT2006)"); + } + + [Test] + public void ConversionErrorWithNonNullValueEmitsNoNodeSetEvenWhenDiagnosticSuppressedTest() + { + // A valid preservation envelope whose native projection carries a + // conflicting browse name: the envelope still restores a (non-null) + // NodeSet, but the native-consistency check reports an error. The + // wrapper must exclude this errored result so no generated model is + // emitted, independent of whether MODELGEN031 is later suppressed. + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string conflicted = TamperNativeProjectionBrowseName( + BuildDemoModelWotEnvelopeJson(nodeSetXml, includeEnvelope: true)); + + WotConversionOutcome outcome = WotNodeSetAdditionalText.Convert( + EmbeddedText.FromContent("Conflict.tm.json", conflicted), + new NodesetFileOptions(), + default); + + Assert.That( + outcome.Diagnostics.Any(d => + d.Id == "MODELGEN031" && + d.GetMessage(CultureInfo.InvariantCulture).Contains("WOT3000", StringComparison.Ordinal)), + Is.True, + "a native-projection conflict should be reported as a MODELGEN031 error " + + "(WotDiagnosticCode.NativeProjectionConflict / WOT3000)"); + Assert.That( + outcome.NodeSetText, + Is.Null, + "a conversion result with an error diagnostic must not yield a NodeSet even when a " + + "(partial) value was produced and the MODELGEN031 diagnostic could be suppressed"); + } + + [Test] + public void CollidingWotInputsProduceDiagnosticAndOnlyOneIsAcceptedTest() + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + DefaultWotOptions(), + [ + EmbeddedText.FromContent("DemoModel.tm.json", wotJson), + EmbeddedText.FromContent("DemoModel.td.json", wotJson), + EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") + ]); + + Assert.That(runResult.Results[0].Exception, Is.Null); + Assert.That( + diagnostics.Any(d => d.Id == "MODELGEN034"), + Is.True, + "two WoT inputs synthesizing the same virtual NodeSet2 path should report MODELGEN034"); + Assert.That( + runResult.Results[0].GeneratedSources + .Count(s => s.HintName == "DemoModel.Constants.g.cs"), + Is.EqualTo(1), + "exactly one of the colliding inputs should be accepted, not zero or both"); + } + + [Test] + public void WotInputCollidingWithExplicitNodeSet2FileProducesDiagnosticTest() + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); + + (ImmutableArray diagnostics, GeneratorDriverRunResult runResult) = + RunGeneratorLeniently( + DefaultWotOptions(), + [ + EmbeddedText.FromContent("DemoModel.NodeSet2.xml", nodeSetXml), + EmbeddedText.FromContent("DemoModel.tm.json", wotJson), + EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml") + ]); + + Assert.That(runResult.Results[0].Exception, Is.Null); + Assert.That( + diagnostics.Any(d => d.Id == "MODELGEN034"), + Is.True, + "a WoT input synthesizing the same path as an explicit NodeSet2 input should report MODELGEN034"); + Assert.That( + runResult.Results[0].GeneratedSources + .Count(s => s.HintName == "DemoModel.Constants.g.cs"), + Is.EqualTo(1), + "the explicit NodeSet2 input should win; the WoT input should be dropped, not duplicated"); + } + + [Test] + public void WotInputPreservesAdditionalFilesOptionsTest() + { + string nodeSetXml = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + string wotJson = BuildDemoModelWotEnvelopeJson(nodeSetXml); + + var options = DefaultWotOptions(); + options.TextOptions["CustomWot.tm.json"] = new Dictionary + { + ["build_metadata.AdditionalFiles.ModelSourceGeneratorPrefix"] = "CustomWotPrefix", + ["build_metadata.AdditionalFiles.ModelSourceGeneratorName"] = "CustomWotName", + ["build_metadata.AdditionalFiles.ModelSourceGeneratorModelUri"] = + "urn:opcfoundation.org:2024-01:DemoModel", + ["build_metadata.AdditionalFiles.ModelSourceGeneratorVersion"] = "9.9.9" + }; + + // None of the options above throw or are rejected even though + // ModelUri/Version are set explicitly (matching, respectively + // overriding, the values already declared inside the WoT input's + // restored NodeSet2 element) — proving they are applied + // to the WoT-derived NodeSet2 file exactly as they would be to a + // NodeSet2/ModelDesign AdditionalFiles input. + GeneratorRunResult generatorResult = RunDemoModelGenerator( + LanguageVersion.CSharp13, + EmbeddedText.FromContent("CustomWot.tm.json", wotJson), + options); + + Assert.That( + generatorResult.GeneratedSources.Any(s => s.HintName == "CustomWotPrefix.Constants.g.cs"), + Is.True, + "the Prefix metadata from the WoT AdditionalFiles item should be honored " + + "after wrapping the WoT input as a NodeSet2 file"); + Assert.That( + generatorResult.GeneratedSources.Any(s => + s.HintName.StartsWith("DemoModel.", StringComparison.Ordinal)), + Is.False, + "the default DemoModel prefix should not be used once Prefix is overridden"); + } + + [Test] + public void IncrementalRerunChangesOutputForWotContentButNotForUnrelatedFilesTest() + { + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() + .AddCode(new Dictionary().WithOpcUaGeneratedStack(), LanguageVersion.CSharp13); + var options = DefaultWotOptions(); + + string nodeSetXmlV1 = EmbeddedText.From("DemoModel.NodeSet2.xml").GetText()!.ToString(); + const string originalAttr = "BrowseName=\"1:Yellow\" ParentNodeId=\"ns=1;i=125\""; + const string renamedAttr = "BrowseName=\"1:YellowRenamedForIncrementalTest\" ParentNodeId=\"ns=1;i=125\""; + Assert.That(nodeSetXmlV1, Does.Contain(originalAttr)); + string nodeSetXmlV2 = nodeSetXmlV1.Replace(originalAttr, renamedAttr, StringComparison.Ordinal); + Assert.That(nodeSetXmlV2, Is.Not.EqualTo(nodeSetXmlV1)); + + AdditionalText wotV1 = EmbeddedText.FromContent( + "DemoModel.tm.json", BuildDemoModelWotEnvelopeJson(nodeSetXmlV1)); + AdditionalText wotV2 = EmbeddedText.FromContent( + "DemoModel.tm.json", BuildDemoModelWotEnvelopeJson(nodeSetXmlV2)); + AdditionalText unrelatedV1 = EmbeddedText.FromContent("Unrelated.csv", "NodeId,BrowseName\n"); + AdditionalText unrelatedV2 = EmbeddedText.FromContent("Unrelated.csv", "NodeId,BrowseName\n1,Foo\n"); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(LanguageVersion.CSharp13)) + .AddAdditionalTexts([wotV1, EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml"), unrelatedV1]) + .WithUpdatedAnalyzerConfigOptions(options); + + driver = driver.RunGeneratorsAndUpdateCompilation( + compilation, out Compilation _, out ImmutableArray diagnostics1); + Assert.That(diagnostics1, Is.Empty); + string baseline = GetGeneratedSourcesText(driver.GetRunResult()); + + GeneratorDriver contentChangedDriver = driver + .RemoveAdditionalTexts([wotV1]) + .AddAdditionalTexts([wotV2]); + contentChangedDriver = contentChangedDriver.RunGeneratorsAndUpdateCompilation( + compilation, out Compilation _, out ImmutableArray diagnostics2); + Assert.That(diagnostics2, Is.Empty); + string afterContentChange = GetGeneratedSourcesText(contentChangedDriver.GetRunResult()); + Assert.That( + afterContentChange, + Is.Not.EqualTo(baseline), + "changing the WoT input content must change the generated output"); + + GeneratorDriver unrelatedChangedDriver = driver + .RemoveAdditionalTexts([unrelatedV1]) + .AddAdditionalTexts([unrelatedV2]); + unrelatedChangedDriver = unrelatedChangedDriver.RunGeneratorsAndUpdateCompilation( + compilation, out Compilation _, out ImmutableArray diagnostics3); + Assert.That(diagnostics3, Is.Empty); + string afterUnrelatedChange = GetGeneratedSourcesText(unrelatedChangedDriver.GetRunResult()); + Assert.That( + afterUnrelatedChange, + Is.EqualTo(baseline), + "changing an unrelated file must not change the WoT-derived generated output"); + } + [Theory] public void GenerateAndCompileIsa95JobControlNodeSet2Test( LanguageVersion languageVersion) @@ -598,6 +1017,148 @@ private static (ImmutableArray Diagnostics, GeneratorDriverRunResult return (diagnostics, driver.GetRunResult()); } + /// + /// Default per-file/global options shared by the WoT AdditionalFile + /// tests below. + /// + private static AnalyzerOptionsProvider DefaultWotOptions() + { + return new AnalyzerOptionsProvider( + new Dictionary + { + ["build_property.ModelSourceGeneratorStartId"] = "1000", + ["build_property.ModelSourceGeneratorOmitFluentApi"] = "true" + }); + } + + /// + /// Converts a NodeSet2 XML document to a native-first WoT document. + /// Tests that exercise envelope integrity can request the explicit + /// byte-preserving uav:nodeSet archival mode. + /// + private static string BuildDemoModelWotEnvelopeJson( + string nodeSetXml, + string title = null, + bool includeEnvelope = false) + { + using var nodeSetStream = new MemoryStream(Encoding.UTF8.GetBytes(nodeSetXml)); + UANodeSet nodeSet = UANodeSet.Read(nodeSetStream)!; + using WotDocument wot = WotNodeSetConverter.FromNodeSet( + nodeSet, + title, + new WotNodeSetConverterOptions + { + PreservationMode = includeEnvelope + ? WotNodeSetPreservationMode.Always + : WotNodeSetPreservationMode.Never + }); + return Encoding.UTF8.GetString(wot.Utf8Json.ToArray()); + } + + /// + /// Flips one base64 character of the uav:nodeSet preservation + /// envelope's data value so the payload still decodes as valid + /// base64 but its SHA-256 digest no longer matches the recorded + /// sha256 value. + /// + private static string TamperEnvelopeDigestData(string envelopeJson) + { + const string marker = "\"data\": \""; + int index = envelopeJson.IndexOf(marker, StringComparison.Ordinal) + marker.Length; + char[] characters = envelopeJson.ToCharArray(); + characters[index] = characters[index] == 'A' ? 'B' : 'A'; + return new string(characters); + } + + /// + /// Rewrites the first browseName of the uav:nodes native + /// projection so it conflicts with the browse name carried by the + /// preservation envelope. The envelope still restores a valid NodeSet, but + /// the native-consistency check reports a + /// WotDiagnosticCode.NativeProjectionConflict error, producing a + /// conversion result with a non-null value and an error diagnostic. + /// + private static string TamperNativeProjectionBrowseName(string envelopeJson) + { + int nodesStart = envelopeJson.IndexOf("\"uav:nodes\"", StringComparison.Ordinal); + const string marker = "\"browseName\": \""; + int start = envelopeJson.IndexOf(marker, nodesStart, StringComparison.Ordinal); + int valueStart = start + marker.Length; + int valueEnd = envelopeJson.IndexOf('"', valueStart); + return new StringBuilder(envelopeJson.Length + 9) + .Append(envelopeJson, 0, valueEnd) + .Append("_MISMATCH") + .Append(envelopeJson, valueEnd, envelopeJson.Length - valueEnd) + .ToString(); + } + + /// + /// Runs the generator for a single model AdditionalText (plus its + /// dependency on the DI companion spec) and asserts a clean, + /// compiling run — for tests where the WoT input is expected to + /// succeed outright. + /// + private static GeneratorRunResult RunDemoModelGenerator( + LanguageVersion languageVersion, + AdditionalText modelText, + AnalyzerOptionsProvider options) + { + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() + .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(languageVersion)) + .AddAdditionalTexts([modelText, EmbeddedText.From("Opc.Ua.Di.NodeSet2.xml")]) + .WithUpdatedAnalyzerConfigOptions(options); + + return GenerateAndCompile(driver, compilation); + } + + /// + /// Runs the generator over the given AdditionalFiles without + /// asserting success — for tests that need to inspect diagnostics + /// produced by a malformed, unsupported or colliding WoT input. + /// + private static (ImmutableArray Diagnostics, GeneratorDriverRunResult RunResult) + RunGeneratorLeniently( + AnalyzerOptionsProvider options, + IEnumerable additionalTexts, + LanguageVersion languageVersion = LanguageVersion.CSharp13) + { + var generator = new ModelSourceGenerator(); + var host = new ModelSourceGeneratorHoist(generator); + CSharpCompilation compilation = OptimizationLevel.Release.CreateCompilation() + .AddCode(new Dictionary().WithOpcUaGeneratedStack(), languageVersion); + + GeneratorDriver driver = CSharpGeneratorDriver.Create(host) + .WithUpdatedParseOptions(new CSharpParseOptions() + .WithKind(SourceCodeKind.Regular) + .WithLanguageVersion(languageVersion)) + .AddAdditionalTexts([.. additionalTexts]) + .WithUpdatedAnalyzerConfigOptions(options); + + return driver.RunGeneratorsForDiagnostics(compilation); + } + + /// + /// Concatenates every generated source (ordered deterministically by + /// hint name) into a single comparable string, used by the + /// incremental re-run test to detect whether generated output + /// changed between runs. + /// + private static string GetGeneratedSourcesText(GeneratorDriverRunResult runResult) + { + return string.Join( + "\n----\n", + runResult.Results[0].GeneratedSources + .OrderBy(s => s.HintName, StringComparer.Ordinal) + .Select(s => s.HintName + ":\n" + s.SourceText)); + } + private static string ValidateXmlSchema( LanguageVersion languageVersion, GeneratorRunResult generatorResult) diff --git a/tests/Opc.Ua.Types.Tests/Wot/NodeSetComparerTests.cs b/tests/Opc.Ua.Types.Tests/Wot/NodeSetComparerTests.cs new file mode 100644 index 0000000000..08dd845dca --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/NodeSetComparerTests.cs @@ -0,0 +1,108 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Xml; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class NodeSetComparerTests + { + [Test] + public void IdenticalNodeSetsAreEquivalent() + { + NodeSetComparisonResult result = NodeSetComparer.Compare( + WotTestData.CreateReconstructableNodeSet(), + WotTestData.CreateReconstructableNodeSet()); + + Assert.That(result.AreEquivalent, Is.True); + Assert.That(result.Differences, Is.Empty); + } + + [Test] + public void SemanticChangeIsDetected() + { + UANodeSet modified = WotTestData.CreateReconstructableNodeSet(); + modified.Items!.OfType().Single().BrowseName = "1:Changed"; + + NodeSetComparisonResult result = NodeSetComparer.Compare( + WotTestData.CreateReconstructableNodeSet(), + modified); + + Assert.That(result.AreEquivalent, Is.False); + Assert.That(result.Differences, Is.Not.Empty); + } + + [Test] + public void FormattingDifferencesAreNormalized() + { + byte[] indented = WotTestData.Serialize(WotTestData.CreateReconstructableNodeSet()); + + var document = new XmlDocument { XmlResolver = null }; + var settings = new XmlReaderSettings + { + DtdProcessing = DtdProcessing.Prohibit, + XmlResolver = null + }; + using (var stream = new System.IO.MemoryStream(indented)) + using (XmlReader reader = XmlReader.Create(stream, settings)) + { + document.Load(reader); + } + byte[] compact = Encoding.UTF8.GetBytes(document.OuterXml); + + NodeSetComparisonResult result = NodeSetComparer.CompareXml(indented, compact); + + Assert.That(result.AreEquivalent, Is.True); + } + + [Test] + public void RoundtripReportConfirmsNativePreservationWithoutEnvelope() + { + NodeSetRoundtripReport report = NodeSetComparer.Roundtrip( + WotTestData.CreateRichNodeSet()); + + Assert.That(report.NativeProjectionPreserved, Is.True); + Assert.That(report.UsedPreservationEnvelope, Is.False); + Assert.That(report.EnvelopePreserved, Is.False); + Assert.That(report.Comparison.AreEquivalent, Is.True); + Assert.That( + report.Diagnostics.Any(d => d.Severity == WotDiagnosticSeverity.Error), + Is.False); + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotBindingReviewTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotBindingReviewTests.cs new file mode 100644 index 0000000000..e59484a096 --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotBindingReviewTests.cs @@ -0,0 +1,682 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Text.Json; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + /// + /// Covers the OPC UA WoT Binding review revisions: the uav:eventType + /// annotation, portable ExpandedNodeId identity, and HasComponent-subtype + /// typed reference links. + /// + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotBindingReviewTests + { + private const string Context = + "\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\"," + + "{\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"ua\":\"http://opcfoundation.org/UA/\"}],"; + + private static readonly string[] s_orderedStageIds = + [ + "nsu=urn:demo:pump;i=2001", + "nsu=urn:demo:pump;i=2002" + ]; + + private static readonly string[] s_portableBrowseNameNamespaces = + [ + "urn:opcua:wot:synthesized", + "urn:demo:pump", + "urn:demo:measurement" + ]; + + // ---- uav:eventType (Section 5.2) ----------------------------------- + + [Test] + public void EventAffordanceEmitsEventTypeAnnotationAndIsEvent() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet(WotTestData.CreateRichNodeSet()); + + JsonElement overTemp = document.Events["OverTemperatureEventType"]; + Assert.That(overTemp.GetProperty("@type").GetString(), Is.EqualTo("uav:eventType")); + Assert.That(overTemp.GetProperty("uav:isEvent").GetBoolean(), Is.True); + } + + [Test] + public void EventTypeRootProjectsEventTypeAnnotation() + { + var nodeSet = new UANodeSet + { + NamespaceUris = ["urn:demo:events"], + Models = [new ModelTableEntry { ModelUri = "urn:demo:events" }], + Items = + [ + new UAObjectType + { + NodeId = "ns=1;i=1002", + BrowseName = "1:OverTemperatureEventType", + DisplayName = [new Export.LocalizedText { Value = "OverTemperatureEventType" }], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=2041" } + ] + } + ] + }; + + using WotDocument document = WotNodeSetConverter.FromNodeSet(nodeSet); + + string[] types = document.TypeTokens.ToArray(); + Assert.That(types, Does.Contain("uav:eventType")); + Assert.That(types, Does.Not.Contain("uav:objectType")); + Assert.That( + document.RootElement.GetProperty("uav:isEvent").GetBoolean(), Is.True); + } + + [Test] + public void EventTypeAnnotatedThingModelSynthesizesBaseEventTypeSubtype() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:eventType\"]," + + "\"title\":\"OverTemperatureEventType\"," + + "\"uav:browseName\":\"1:OverTemperatureEventType\"," + + "\"uav:isEvent\":true}"; + + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + + UAObjectType root = nodeSet.Items!.OfType().Single(); + Assert.That( + root.References!.Any(r => + r.ReferenceType == "HasSubtype" && !r.IsForward && r.Value == "i=2041"), + Is.True, + "An event-type Thing Model must derive from BaseEventType (i=2041)."); + } + + [Test] + public void ContradictoryEventTypeAndIsEventFalseIsRejected() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"events\":{\"overTemp\":{\"@type\":\"uav:eventType\"," + + "\"uav:isEvent\":false,\"uav:browseName\":\"1:OverTemp\"}}}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.EventAnnotationConflict), + Is.True); + Assert.That(result.HasErrors, Is.True); + } + + // ---- Portable identity (Section 5.1.1) ----------------------------- + + [Test] + public void ForwardIdentityTermsArePortableExpandedNodeIds() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet(WotTestData.CreateRichNodeSet()); + + Assert.That( + document.RootElement.GetProperty("uav:id").GetString(), + Is.EqualTo("nsu=urn:test:model;i=1001")); + Assert.That( + document.Properties["Speed"].GetProperty("uav:id").GetString(), + Is.EqualTo("nsu=urn:test:model;i=6001")); + Assert.That( + document.Events["OverTemperatureEventType"].GetProperty("uav:id").GetString(), + Is.EqualTo("nsu=urn:test:model;i=1002")); + + // No emitted WoT-native identity uses the session-local ns= form. + string text = Encoding.UTF8.GetString(document.Utf8Json.ToArray()); + int nativeProjection = text.IndexOf( + "\"uav:nodes\"", + System.StringComparison.Ordinal); + string readable = nativeProjection < 0 + ? text + : text.Substring(0, nativeProjection); + Assert.That(readable, Does.Not.Contain("\"ns=1;")); + } + + [Test] + public void NamespaceZeroIdentityKeepsCanonicalForm() + { + var nodeSet = new UANodeSet + { + NamespaceUris = ["urn:demo:x"], + Models = [new ModelTableEntry { ModelUri = "urn:demo:x" }], + Items = + [ + new UAObjectType + { + // A namespace-0 NodeId keeps its canonical i= form. + NodeId = "i=1500", + BrowseName = "1:CanonicalType", + DisplayName = [new Export.LocalizedText { Value = "CanonicalType" }], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=58" } + ] + } + ] + }; + + using WotDocument document = WotNodeSetConverter.FromNodeSet(nodeSet); + + Assert.That( + document.RootElement.GetProperty("uav:id").GetString(), Is.EqualTo("i=1500")); + } + + [Test] + public void ForwardBrowseNamesUsePortableQualifiedNameSyntax() + { + using WotDocument document = + WotNodeSetConverter.FromNodeSet(WotTestData.CreateRichNodeSet()); + + Assert.That( + document.RootElement.GetProperty("uav:browseName").GetString(), + Is.EqualTo("ns1:MachineType")); + Assert.That( + document.Properties["Speed"].GetProperty("uav:browseName").GetString(), + Is.EqualTo("ns1:Speed")); + } + + [Test] + public void ContextQualifiedBrowseNamesMapToNodeSetNamespaceIndexes() + { + const string json = + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"pump\":\"urn:demo:pump\"," + + "\"measurement\":\"urn:demo:measurement\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\"," + + "\"uav:browseName\":\"pump:PumpType\"," + + "\"properties\":{\"speed\":{" + + "\"uav:browseName\":\"measurement:Speed\"," + + "\"type\":\"number\"}}}"; + + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + + Assert.That( + nodeSet.NamespaceUris, + Is.EqualTo(s_portableBrowseNameNamespaces)); + Assert.That( + nodeSet.Items!.OfType().Single().BrowseName, + Is.EqualTo("2:PumpType")); + Assert.That( + nodeSet.Items!.OfType().Single().BrowseName, + Is.EqualTo("3:Speed")); + } + + [Test] + public void NumericReadableBrowseNameIsDiagnosed() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = + WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That( + result.Diagnostics.Any(d => + d.Code == WotDiagnosticCode.NonPortableQualifiedName), + Is.True); + } + + [Test] + public void PortableIdentityIsStableAcrossNamespaceTableReordering() + { + using WotDocument first = WotNodeSetConverter.FromNodeSet( + CreateReorderableNodeSet(["urn:demo:a", "urn:demo:b"], aIndex: 1)); + using WotDocument second = WotNodeSetConverter.FromNodeSet( + CreateReorderableNodeSet(["urn:demo:b", "urn:demo:a"], aIndex: 2)); + + // The same URI-anchored identity survives the namespace-table swap, + // even though the raw NodeSet NodeIds used different indices. + Assert.That( + first.RootElement.GetProperty("uav:id").GetString(), + Is.EqualTo("nsu=urn:demo:a;i=1001")); + Assert.That( + second.RootElement.GetProperty("uav:id").GetString(), + Is.EqualTo(first.RootElement.GetProperty("uav:id").GetString())); + Assert.That( + second.Properties["Speed"].GetProperty("uav:id").GetString(), + Is.EqualTo(first.Properties["Speed"].GetProperty("uav:id").GetString())); + } + + [Test] + public void SynthesisDiagnosesSessionLocalNsIndexInPortableField() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"uav:id\":\"ns=1;i=1001\"}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.NonPortableIdentity), + Is.True); + // A non-portable identity is diagnosed, not fatal: conversion still succeeds. + Assert.That(result.Value, Is.Not.Null); + } + + // ---- HasComponent subtypes (Section 5.3) --------------------------- + + [Test] + public void HasComponentSubtypeEmitsDiscoveryAndReferenceTypeRelation() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet(CreateOrderedComponentNodeSet()); + + string[] children = document.RootElement.GetProperty("uav:hasComponent") + .EnumerateArray().Select(e => e.GetString()!).ToArray(); + Assert.That(children, Is.EquivalentTo(s_orderedStageIds)); + + JsonElement links = document.RootElement.GetProperty("links"); + Assert.That(links.GetArrayLength(), Is.EqualTo(2)); + foreach (JsonElement link in links.EnumerateArray()) + { + Assert.That( + link.GetProperty("rel").GetString(), + Is.EqualTo("ua:HasOrderedComponent")); + Assert.That(link.GetProperty("uav:refId").GetString(), Is.EqualTo("i=49")); + Assert.That(link.GetProperty("href").GetString(), Does.StartWith("nsu=urn:demo:pump;")); + Assert.That(link.GetProperty("uav:refName").GetString(), Does.StartWith("Stage_")); + } + } + + [Test] + public void ReferenceTypeRelationPinnedComponentRecreatesExactSubtype() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"uav:hasComponent\":[\"nsu=urn:demo:pump;i=2001\"]," + + "\"links\":[{\"rel\":\"ua:HasOrderedComponent\"," + + "\"href\":\"nsu=urn:demo:pump;i=2001\",\"uav:refId\":\"i=49\"," + + "\"uav:refName\":\"Stage_1\"}]}"; + + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + + UAObjectType root = nodeSet.Items!.OfType().Single(); + var toTarget = root.References! + .Where(r => r.Value == "nsu=urn:demo:pump;i=2001").ToArray(); + Assert.That(toTarget, Has.Length.EqualTo(1), + "The pinned component must not be emitted twice."); + Assert.That(toTarget[0].ReferenceType, Is.EqualTo("i=49")); + Assert.That(toTarget[0].IsForward, Is.True); + } + + [Test] + public void ReferenceTypeModelNameResolvesWithoutFallbackWhenUnique() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"uav:hasComponent\":[\"nsu=urn:demo:pump;i=2001\"]," + + "\"links\":[{\"rel\":\"ua:HasOrderedComponent\"," + + "\"href\":\"nsu=urn:demo:pump;i=2001\"," + + "\"uav:refName\":\"Stage_1\"}]}"; + + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + + UAObjectType root = nodeSet.Items!.OfType().Single(); + Reference reference = root.References! + .Single(r => r.Value == "nsu=urn:demo:pump;i=2001"); + Assert.That(reference.ReferenceType, Is.EqualTo("i=49")); + } + + [Test] + public void CustomReferenceTypeModelNameUsesExpandedNodeIdFallback() + { + const string json = + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"pump\":\"urn:demo:pump#\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"links\":[{\"rel\":\"pump:FlowsTo\"," + + "\"href\":\"nsu=urn:demo:pump;i=2001\"," + + "\"uav:refId\":\"nsu=urn:demo:pump;i=4001\"}]}"; + + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + + UAObjectType root = nodeSet.Items!.OfType().Single(); + Assert.That( + root.References!.Any(r => + r.ReferenceType == "nsu=urn:demo:pump;i=4001" && + r.Value == "nsu=urn:demo:pump;i=2001"), + Is.True); + } + + [Test] + public void ConflictingReferenceTypeNameAndNodeIdIsRejected() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"links\":[{\"rel\":\"ua:HasOrderedComponent\"," + + "\"href\":\"nsu=urn:demo:pump;i=2001\"," + + "\"uav:refId\":\"i=47\"}]}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = + WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.HasErrors, Is.True); + Assert.That( + result.Diagnostics.Any(d => + d.Code == WotDiagnosticCode.ModelConceptConflict), + Is.True); + } + + [Test] + public void TypeModelNameRemainsSemanticHintBesideExpandedNodeId() + { + const string json = + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"pump\":\"urn:demo:pump#\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"properties\":{\"speed\":{\"uav:browseName\":\"1:Speed\"," + + "\"type\":\"number\",\"uav:mapToTypeName\":\"pump:Measurement\"," + + "\"uav:mapToType\":\"nsu=urn:demo:pump;i=3010\"}}}"; + + using WotDocument source = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = + WotNodeSetConverter.ToNodeSetResult(source); + + Assert.That(result.HasErrors, Is.False); + Assert.That( + result.Diagnostics.Any(d => + d.Code == WotDiagnosticCode.NonPortableIdentity), + Is.False); + + using WotDocument restored = WotNodeSetConverter.FromNodeSet(result.Value!); + JsonElement speed = restored.RootElement + .GetProperty("properties") + .GetProperty("Speed"); + Assert.That( + speed.GetProperty("uav:mapToTypeName").GetString(), + Is.EqualTo("pump:Measurement")); + Assert.That( + speed.GetProperty("uav:mapToType").GetString(), + Is.EqualTo("nsu=urn:demo:pump;i=3010")); + } + + [TestCase("uav:mapToNodeId")] + [TestCase("uav:mapToType")] + public void MappingIdentifiersRejectCompactModelNames(string term) + { + string json = + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"pump\":\"urn:demo:pump#\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\"," + + "\"uav:browseName\":\"nsu=urn:demo:pump;PumpType\"," + + "\"properties\":{\"speed\":{\"type\":\"number\",\"" + + term + "\":\"pump:Measurement\"}}}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = + WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.HasErrors, Is.True); + Assert.That( + result.Diagnostics.Any(d => + d.Code == WotDiagnosticCode.ValidationError), + Is.True); + } + + [Test] + public void GeneratedContextBindsBaseAndNodeSetNamespaces() + { + using WotDocument document = + WotNodeSetConverter.FromNodeSet(WotTestData.CreateRichNodeSet()); + + JsonElement prefixes = document.RootElement.GetProperty("@context")[1]; + Assert.That( + prefixes.GetProperty("ua").GetString(), + Is.EqualTo("http://opcfoundation.org/UA/")); + Assert.That( + prefixes.GetProperty("ns1").GetString(), + Is.EqualTo("urn:test:model")); + } + + [Test] + public void DefinedBindingRelationIsNotTreatedAsUnknownModelConcept() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"links\":[{\"rel\":\"uav:componentModel\"," + + "\"href\":\"nsu=urn:demo:pump;i=2001\"," + + "\"uav:refId\":\"i=47\",\"uav:refName\":\"Stage\"}]}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = + WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.HasErrors, Is.False); + Assert.That( + result.Diagnostics.Any(d => + d.Code == WotDiagnosticCode.ModelConceptUnresolved), + Is.False); + } + + [Test] + public void ExternalIriRelationIsPreservedRatherThanRejected() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"links\":[{\"rel\":\"https://schema.org/about\"," + + "\"href\":\"https://example.com/about\"}]}"; + + using WotDocument source = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = + WotNodeSetConverter.ToNodeSetResult(source); + + Assert.That(result.HasErrors, Is.False); + using WotDocument restored = WotNodeSetConverter.FromNodeSet(result.Value!); + JsonElement link = restored.RootElement.GetProperty("links")[0]; + Assert.That( + link.GetProperty("rel").GetString(), + Is.EqualTo("https://schema.org/about")); + } + + [Test] + public void UnpinnedComponentDefaultsToPlainHasComponent() + { + string json = + "{" + Context + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"uav:hasComponent\":[\"nsu=urn:demo:pump;i=2001\"]}"; + + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + + UAObjectType root = nodeSet.Items!.OfType().Single(); + Reference reference = root.References! + .Single(r => r.Value == "nsu=urn:demo:pump;i=2001"); + Assert.That(reference.ReferenceType, Is.EqualTo("HasComponent")); + Assert.That(reference.IsForward, Is.True); + } + + [Test] + public void HasComponentSubtypeRoundTripsExactlyThroughReadableMapping() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet(CreateOrderedComponentNodeSet()); + + // Rebuild a native (envelope-free) Thing Model from the emitted + // readable surface and confirm the ordered components survive. + string readable = BuildReadableOnly(document); + UANodeSet restored = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(readable)); + + UAObjectType root = restored.Items!.OfType().Single(); + int ordered = root.References!.Count(r => r.ReferenceType == "i=49" && r.IsForward); + Assert.That(ordered, Is.EqualTo(2)); + Assert.That( + root.References!.Any(r => r.ReferenceType == "HasComponent" && r.IsForward), + Is.False, + "A pinned ordered component must not degrade to plain HasComponent."); + } + + private static string BuildReadableOnly(WotDocument document) + { + // Strip the preservation envelope and native projection so ToNodeSet + // exercises the synthesis (reverse conversion) path rather than the + // exact envelope restore. + using var stream = new System.IO.MemoryStream(); + using (var writer = new Utf8JsonWriter(stream)) + { + writer.WriteStartObject(); + foreach (JsonProperty member in document.RootElement.EnumerateObject()) + { + if (member.Name is "uav:nodeSet" or "uav:nodes") + { + continue; + } + writer.WritePropertyName(member.Name); + member.Value.WriteTo(writer); + } + writer.WriteEndObject(); + } + return Encoding.UTF8.GetString(stream.ToArray()); + } + + private static UANodeSet CreateReorderableNodeSet(string[] namespaceUris, int aIndex) + { + string prefix = "ns=" + aIndex.ToString(System.Globalization.CultureInfo.InvariantCulture) + ";"; + return new UANodeSet + { + NamespaceUris = namespaceUris, + Models = [new ModelTableEntry { ModelUri = "urn:demo:a" }], + Items = + [ + new UAObjectType + { + NodeId = prefix + "i=1001", + BrowseName = aIndex + ":PumpType", + DisplayName = [new Export.LocalizedText { Value = "PumpType" }], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=58" }, + new Reference { ReferenceType = "HasComponent", IsForward = true, Value = prefix + "i=6001" } + ] + }, + new UAVariable + { + NodeId = prefix + "i=6001", + BrowseName = aIndex + ":Speed", + DisplayName = [new Export.LocalizedText { Value = "Speed" }], + DataType = "Double", + AccessLevel = 1, + ParentNodeId = prefix + "i=1001", + References = + [ + new Reference { ReferenceType = "HasTypeDefinition", IsForward = true, Value = "i=63" }, + new Reference { ReferenceType = "HasComponent", IsForward = false, Value = prefix + "i=1001" } + ] + } + ] + }; + } + + private static UANodeSet CreateOrderedComponentNodeSet() + { + return new UANodeSet + { + NamespaceUris = ["urn:demo:pump"], + Models = [new ModelTableEntry { ModelUri = "urn:demo:pump" }], + Items = + [ + new UAObjectType + { + NodeId = "ns=1;i=1001", + BrowseName = "1:PumpType", + DisplayName = [new Export.LocalizedText { Value = "PumpType" }], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=58" }, + new Reference { ReferenceType = "HasOrderedComponent", IsForward = true, Value = "ns=1;i=2001" }, + new Reference { ReferenceType = "HasOrderedComponent", IsForward = true, Value = "ns=1;i=2002" } + ] + }, + new UAObject + { + NodeId = "ns=1;i=2001", + BrowseName = "1:Stage_1", + DisplayName = [new Export.LocalizedText { Value = "Stage_1" }], + References = + [ + new Reference { ReferenceType = "HasTypeDefinition", IsForward = true, Value = "i=58" }, + new Reference { ReferenceType = "HasOrderedComponent", IsForward = false, Value = "ns=1;i=1001" } + ] + }, + new UAObject + { + NodeId = "ns=1;i=2002", + BrowseName = "1:Stage_2", + DisplayName = [new Export.LocalizedText { Value = "Stage_2" }], + References = + [ + new Reference { ReferenceType = "HasTypeDefinition", IsForward = true, Value = "i=58" }, + new Reference { ReferenceType = "HasOrderedComponent", IsForward = false, Value = "ns=1;i=1001" } + ] + } + ] + }; + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotDocumentTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotDocumentTests.cs new file mode 100644 index 0000000000..1d2643783e --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotDocumentTests.cs @@ -0,0 +1,140 @@ +/* ======================================================================== + * 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.Text; +using System.Text.Json; +using NUnit.Framework; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotDocumentTests + { + [Test] + public void ParseAndWritePreservesUnknownMembersExactly() + { + byte[] json = Encoding.UTF8.GetBytes( + "{\"@context\":[],\"title\":\"T\",\"vendor:unknown\":{\"b\":2,\"a\":1}}"); + + using WotDocument document = WotDocument.Parse(json); + using var output = new System.IO.MemoryStream(); + document.Write(output); + + Assert.That(output.ToArray(), Is.EqualTo(json)); + } + + [Test] + public void LexicalSurfaceExposesTypedAccess() + { + byte[] json = Encoding.UTF8.GetBytes( + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\"]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"id\":\"urn:pump\"," + + "\"uav:browseName\":\"1:PumpType\"," + + "\"properties\":{\"speed\":{\"type\":\"number\"}}," + + "\"actions\":{\"reset\":{}},\"events\":{\"hot\":{}}," + + "\"links\":[{\"rel\":\"tm:extends\",\"href\":\"x\"}]," + + "\"securityDefinitions\":{\"nosec_sc\":{\"scheme\":\"nosec\"}}," + + "\"schemaDefinitions\":{\"S\":{\"type\":\"object\"}}}"); + + using WotDocument document = WotDocument.Parse(json); + + Assert.That(document.Kind, Is.EqualTo(WotDocumentKind.ThingModel)); + Assert.That(document.Title, Is.EqualTo("PumpType")); + Assert.That(document.Id, Is.EqualTo("urn:pump")); + Assert.That(document.TypeTokens, Does.Contain("uav:objectType")); + Assert.That(document.Properties.ContainsKey("speed"), Is.True); + Assert.That(document.Actions.ContainsKey("reset"), Is.True); + Assert.That(document.Events.ContainsKey("hot"), Is.True); + Assert.That(document.Links, Has.Count.EqualTo(1)); + Assert.That(document.SecurityDefinitions.ContainsKey("nosec_sc"), Is.True); + Assert.That(document.SchemaDefinitions.ContainsKey("S"), Is.True); + Assert.That(document.TryGetUav("browseName", out JsonElement browseName), Is.True); + Assert.That(browseName.GetString(), Is.EqualTo("1:PumpType")); + } + + [Test] + public void JsonPointerResolvesNestedMembersIncludingEscapes() + { + byte[] json = Encoding.UTF8.GetBytes( + "{\"properties\":{\"speed\":{\"uav:unit~x\":\"rpm\",\"items\":[10,20]}}}"); + + using WotDocument document = WotDocument.Parse(json); + + Assert.That( + document.TryEvaluatePointer("/properties/speed/items/1", out JsonElement item), + Is.True); + Assert.That(item.GetInt32(), Is.EqualTo(20)); + + Assert.That( + document.TryEvaluatePointer("/properties/speed/uav:unit~0x", out JsonElement unit), + Is.True); + Assert.That(unit.GetString(), Is.EqualTo("rpm")); + + Assert.That(document.TryEvaluatePointer("/missing", out _), Is.False); + } + + [Test] + public void CanonicalWriterProducesDeterministicSortedOutput() + { + byte[] first = Encoding.UTF8.GetBytes( + "{ \"b\": 2, \"a\": 1, \"nested\": { \"y\": 2, \"x\": 1 } }"); + byte[] second = Encoding.UTF8.GetBytes( + "{\"a\":1,\"nested\":{\"x\":1,\"y\":2},\"b\":2}"); + + using WotDocument firstDocument = WotDocument.Parse(first); + using WotDocument secondDocument = WotDocument.Parse(second); + + byte[] firstCanonical = firstDocument.ToCanonicalUtf8(); + byte[] secondCanonical = secondDocument.ToCanonicalUtf8(); + + Assert.That(firstCanonical, Is.EqualTo(secondCanonical)); + Assert.That( + Encoding.UTF8.GetString(firstCanonical), + Is.EqualTo("{\"a\":1,\"b\":2,\"nested\":{\"x\":1,\"y\":2}}")); + } + + [Test] + public void CanonicalWriterIsSeparateFromExactWrite() + { + byte[] json = Encoding.UTF8.GetBytes("{ \"b\" : 2, \"a\" : 1 }"); + using WotDocument document = WotDocument.Parse(json); + + using var exact = new System.IO.MemoryStream(); + document.Write(exact); + + Assert.That(exact.ToArray(), Is.EqualTo(json)); + Assert.That(document.ToCanonicalUtf8(), Is.Not.EqualTo(json)); + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotEnvelopeTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotEnvelopeTests.cs new file mode 100644 index 0000000000..91f1cbbdb8 --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotEnvelopeTests.cs @@ -0,0 +1,318 @@ +/* ======================================================================== + * 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.Globalization; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.RegularExpressions; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotEnvelopeTests + { + [Test] + public void EnvelopeRoundTripsAllNodeClassesAndExtensions() + { + UANodeSet source = WotTestData.CreateRichNodeSet(); + + using WotDocument document = WotNodeSetConverter.FromNodeSet( + source, + options: AlwaysPreserve()); + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document); + + Assert.That(WotTestData.Serialize(restored), Is.EqualTo(WotTestData.Serialize(source))); + } + + [Test] + public void EnvelopeDigestIsLowercaseHex() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateRichNodeSet(), + options: AlwaysPreserve()); + + string digest = document.RootElement + .GetProperty("uav:nodeSet") + .GetProperty("sha256") + .GetString()!; + + Assert.That(digest, Has.Length.EqualTo(64)); + Assert.That(digest, Does.Match("^[0-9a-f]{64}$")); + } + + [Test] + public void FromNodeSetEmitsNativeAffordances() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet(WotTestData.CreateRichNodeSet()); + + Assert.That(document.Kind, Is.EqualTo(WotDocumentKind.ThingModel)); + + Assert.That(document.Properties.ContainsKey("Speed"), Is.True); + JsonElement speed = document.Properties["Speed"]; + Assert.That(speed.GetProperty("@type").GetString(), Is.EqualTo("uav:variableType")); + Assert.That(speed.GetProperty("type").GetString(), Is.EqualTo("number")); + Assert.That(speed.GetProperty("observable").GetBoolean(), Is.True); + Assert.That(speed.GetProperty("uav:modellingRule").GetString(), Is.EqualTo("Mandatory")); + + Assert.That(document.Actions.ContainsKey("Reset"), Is.True); + Assert.That( + document.Actions["Reset"].GetProperty("uav:modellingRule").GetString(), + Is.EqualTo("Optional")); + + Assert.That(document.Events.ContainsKey("OverTemperatureEventType"), Is.True); + Assert.That( + document.Events["OverTemperatureEventType"].GetProperty("uav:isEvent").GetBoolean(), + Is.True); + } + + [Test] + public void FromNodeSetEmitsNativeProjectionForEveryNode() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet(WotTestData.CreateRichNodeSet()); + + Assert.That(document.TryGetNativeProjection(out JsonElement projection), Is.True); + JsonElement nodes = projection.GetProperty("nodes"); + Assert.That(nodes.GetArrayLength(), Is.EqualTo(WotTestData.CreateRichNodeSet().Items!.Length)); + + JsonElement objectType = nodes.EnumerateArray() + .First(n => n.GetProperty("nodeId").GetString() == "ns=1;i=1001"); + Assert.That(objectType.GetProperty("nodeClass").GetString(), Is.EqualTo("ObjectType")); + Assert.That(objectType.GetProperty("browseName").GetString(), Is.EqualTo("1:MachineType")); + } + + [Test] + public void DigestMismatchProducesDiagnostic() + { + using WotDocument original = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateRichNodeSet(), + options: AlwaysPreserve()); + string json = Encoding.UTF8.GetString(original.Utf8Json.ToArray()); + const string marker = "\"data\": \""; + int index = json.IndexOf(marker, StringComparison.Ordinal) + marker.Length; + char[] characters = json.ToCharArray(); + characters[index] = characters[index] == 'A' ? 'B' : 'A'; + + using WotDocument tampered = WotDocument.Parse( + Encoding.UTF8.GetBytes(new string(characters))); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(tampered); + + Assert.That(result.HasErrors, Is.True); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.DigestMismatch), + Is.True); + } + + [Test] + public void NativeProjectionConflictIsReportedNotSilentlyResolved() + { + using WotDocument original = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateRichNodeSet(), + options: AlwaysPreserve()); + string json = Encoding.UTF8.GetString(original.Utf8Json.ToArray()); + + // Rewrite the plaintext BrowseName; the base64 envelope keeps the + // authoritative value so the native projection now conflicts. + string conflicted = json.Replace("1:MachineType", "1:Tampered", StringComparison.Ordinal); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(conflicted)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.NativeProjectionConflict), + Is.True); + Assert.That(result.HasErrors, Is.True); + } + + [Test] + public void EnvelopeRebuildsExactNodeSetForSourceGeneratorConsumption() + { + UANodeSet source = WotTestData.CreateReconstructableNodeSet(); + + using WotDocument document = WotNodeSetConverter.FromNodeSet( + source, + options: AlwaysPreserve()); + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document.Utf8Json); + + Assert.That(WotTestData.Serialize(restored), Is.EqualTo(WotTestData.Serialize(source))); + } + + [Test] + public void MissingDigestIsRejectedAsMandatory() + { + using WotDocument original = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateReconstructableNodeSet(), + options: AlwaysPreserve()); + string json = Encoding.UTF8.GetString(original.Utf8Json.ToArray()); + string withoutDigest = RemoveJsonStringProperty(json, "sha256"); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(withoutDigest)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.Value, Is.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.InvalidDigest), + Is.True); + } + + [Test] + public void MalformedDigestIsRejected() + { + using WotDocument original = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateReconstructableNodeSet(), + options: AlwaysPreserve()); + string json = Encoding.UTF8.GetString(original.Utf8Json.ToArray()); + string malformed = ReplaceJsonStringProperty(json, "sha256", "not-a-valid-digest"); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(malformed)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.Value, Is.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.InvalidDigest), + Is.True); + } + + [Test] + public void MalformedNodeSetXmlProducesDiagnosticInsteadOfThrowing() + { + byte[] payload = Encoding.UTF8.GetBytes("this is not a valid NodeSet2 XML document at all"); + string json = BuildEnvelopeJson(payload); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + + WotConversionResult result = null; + Assert.That( + () => result = WotNodeSetConverter.ToNodeSetResult(document), + Throws.Nothing); + Assert.That(result!.Value, Is.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.MalformedNodeSet), + Is.True); + } + + [Test] + public void UnsupportedEncodingIsRejected() + { + using WotDocument original = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateReconstructableNodeSet(), + options: AlwaysPreserve()); + string json = Encoding.UTF8.GetString(original.Utf8Json.ToArray()); + string tampered = ReplaceJsonStringProperty(json, "encoding", "base64url"); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(tampered)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.Value, Is.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.UnsupportedEncoding), + Is.True); + } + + [Test] + public void Base64IsTheOnlyAcceptedEncodingPerSpecAndRoundTrips() + { + UANodeSet source = WotTestData.CreateReconstructableNodeSet(); + using WotDocument document = WotNodeSetConverter.FromNodeSet( + source, + options: AlwaysPreserve()); + + string encoding = document.RootElement + .GetProperty("uav:nodeSet") + .GetProperty("encoding") + .GetString()!; + Assert.That(encoding, Is.EqualTo("base64")); + + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document); + Assert.That(WotTestData.Serialize(restored), Is.EqualTo(WotTestData.Serialize(source))); + } + + private static string RemoveJsonStringProperty(string json, string propertyName) + { + return Regex.Replace( + json, + "\"" + Regex.Escape(propertyName) + "\":\\s*\"[^\"]*\",?\\s*", + string.Empty); + } + + private static WotNodeSetConverterOptions AlwaysPreserve() + { + return new WotNodeSetConverterOptions + { + PreservationMode = WotNodeSetPreservationMode.Always + }; + } + + private static string ReplaceJsonStringProperty(string json, string propertyName, string newValue) + { + return Regex.Replace( + json, + "\"" + Regex.Escape(propertyName) + "\":\\s*\"[^\"]*\"", + "\"" + propertyName + "\": \"" + newValue + "\""); + } + + private static string BuildEnvelopeJson(byte[] nodeSetBytes) + { + byte[] digest = ComputeSha256(nodeSetBytes); + return "{\"@type\":\"tm:ThingModel\",\"uav:nodeSet\":{" + + "\"@type\":\"uav:nodeSet\",\"contentType\":\"application/opcua-nodeset+xml\"," + + "\"encoding\":\"base64\"," + + "\"sha256\":\"" + ToLowerHexString(digest) + "\"," + + "\"data\":\"" + Convert.ToBase64String(nodeSetBytes) + "\"}}"; + } + + 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 ToLowerHexString(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/tests/Opc.Ua.Types.Tests/Wot/WotNativeFirstRoundtripTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotNativeFirstRoundtripTests.cs new file mode 100644 index 0000000000..cfd6e4aa0a --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotNativeFirstRoundtripTests.cs @@ -0,0 +1,371 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Text.Json; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotNativeFirstRoundtripTests + { + [Test] + public void CompleteReadableMappingOmitsStructuredFallback() + { + var source = new UANodeSet + { + NamespaceUris = ["urn:test:readable"], + Models = [new ModelTableEntry { ModelUri = "urn:test:readable" }], + Items = + [ + new UAObjectType + { + NodeId = "ns=1;s=ReadableType", + BrowseName = "1:ReadableType", + DisplayName = + [ + new Opc.Ua.Export.LocalizedText + { + Value = "ReadableType" + } + ], + References = + [ + new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + Value = "i=58" + } + ] + } + ] + }; + + using WotDocument document = WotNodeSetConverter.FromNodeSet(source); + + Assert.That(document.TryGetNativeProjection(out _), Is.False); + Assert.That(document.TryGetEnvelope(out _), Is.False); + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document); + Assert.That(NodeSetComparer.Compare(source, restored).AreEquivalent, Is.True); + } + + [Test] + public void IncompleteReadableMappingUsesStructuredFallbackWithoutEnvelope() + { + UANodeSet source = WotTestData.CreateRichNodeSet(); + + using WotDocument document = WotNodeSetConverter.FromNodeSet(source); + + Assert.That(document.TryGetNativeProjection(out JsonElement projection), Is.True); + Assert.That( + projection.GetProperty("profileVersion").GetString(), + Is.EqualTo("1.0")); + Assert.That(document.TryGetEnvelope(out _), Is.False); + + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document); + NodeSetComparisonResult comparison = NodeSetComparer.Compare(source, restored); + Assert.That( + comparison.AreEquivalent, + Is.True, + string.Join("; ", comparison.Differences)); + } + + [Test] + public void NeverModeProvesCompleteSchemaRoundtripWithoutFallback() + { + UANodeSet source = WotTestData.CreateRichNodeSet(); + var options = new WotNodeSetConverterOptions + { + PreservationMode = WotNodeSetPreservationMode.Never + }; + + WotConversionResult result = + WotNodeSetConverter.FromNodeSetResult(source, options: options); + + Assert.That(result.HasErrors, Is.False); + Assert.That(result.Value, Is.Not.Null); + using WotDocument document = result.Value!; + Assert.That(document.TryGetEnvelope(out _), Is.False); + + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document, options); + Assert.That( + WotTestData.Serialize(restored), + Is.EqualTo(WotTestData.Serialize(source))); + } + + [Test] + public void WhenRequiredFallsBackOnlyWhenNativeProjectionIsBounded() + { + UANodeSet source = WotTestData.CreateRichNodeSet(); + var fallback = new WotNodeSetConverterOptions + { + MaxNodeCount = 1, + PreservationMode = WotNodeSetPreservationMode.WhenRequired + }; + + WotConversionResult result = + WotNodeSetConverter.FromNodeSetResult(source, options: fallback); + + Assert.That(result.HasErrors, Is.False); + Assert.That(result.Value, Is.Not.Null); + using WotDocument document = result.Value!; + Assert.That(document.TryGetEnvelope(out _), Is.True); + Assert.That( + result.Diagnostics.Any( + d => d.Code == WotDiagnosticCode.NativeProjectionIncomplete), + Is.True); + + var nativeOnly = new WotNodeSetConverterOptions + { + MaxNodeCount = 1, + PreservationMode = WotNodeSetPreservationMode.Never + }; + WotConversionResult rejected = + WotNodeSetConverter.FromNodeSetResult(source, options: nativeOnly); + Assert.That(rejected.Value, Is.Null); + Assert.That(rejected.HasErrors, Is.True); + } + + [Test] + public void UnknownJsonLdResidueSurvivesTwoEnvelopeFreeRoundtrips() + { + const string json = + "{" + + "\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"vendor\":\"urn:vendor:\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"vendor:root\":{\"b\":2,\"a\":1}," + + "\"properties\":{\"speed\":{" + + "\"@type\":\"uav:variableType\",\"uav:browseName\":\"1:Speed\"," + + "\"type\":\"number\",\"readOnly\":true,\"observable\":true," + + "\"forms\":[{\"href\":\"opc.tcp://example.test:4840\"," + + "\"op\":[\"readproperty\"]}]," + + "\"vendor:quality\":{\"mode\":\"good\"}}}}"; + + UANodeSet firstNodeSet = + WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + Assert.That( + firstNodeSet.Extensions!.Any(e => + e.LocalName == "WoTJsonResidue" && + e.NamespaceURI == WotNodeSetConverter.VocabularyNamespace), + Is.True); + + using WotDocument first = WotNodeSetConverter.FromNodeSet(firstNodeSet); + Assert.That(first.TryGetEnvelope(out _), Is.False); + AssertResidue(first); + + UANodeSet secondNodeSet = WotNodeSetConverter.ToNodeSet(first); + using WotDocument second = WotNodeSetConverter.FromNodeSet(secondNodeSet); + Assert.That(second.TryGetEnvelope(out _), Is.False); + AssertResidue(second); + } + + [Test] + public void ContextAndMappedLinkResidueUseStableSelectors() + { + const string json = + "{" + + "\"@context\":[{\"vendor\":\"urn:vendor:\"}," + + "\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"extra\":\"urn:extra:\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"links\":[{\"rel\":\"tm:extends\"," + + "\"href\":\"nsu=urn:base;i=1001\",\"hreflang\":\"en\"}]}"; + + UANodeSet nodeSet = + WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + using WotDocument restored = WotNodeSetConverter.FromNodeSet(nodeSet); + + JsonElement context = restored.RootElement.GetProperty("@context"); + Assert.That( + context[1].GetProperty("extra").GetString(), + Is.EqualTo("urn:extra:")); + Assert.That( + context.EnumerateArray().Any(item => + item.ValueKind == JsonValueKind.Object && + item.TryGetProperty("vendor", out JsonElement vendor) && + vendor.GetString() == "urn:vendor:"), + Is.True); + + JsonElement links = restored.RootElement.GetProperty("links"); + Assert.That(links.GetArrayLength(), Is.EqualTo(1)); + Assert.That(links[0].GetProperty("rel").GetString(), Is.EqualTo("tm:extends")); + Assert.That( + links[0].GetProperty("href").GetString(), + Is.EqualTo("nsu=urn:base;i=1001")); + Assert.That(links[0].GetProperty("hreflang").GetString(), Is.EqualTo("en")); + + UANodeSet secondNodeSet = WotNodeSetConverter.ToNodeSet(restored); + using WotDocument second = WotNodeSetConverter.FromNodeSet(secondNodeSet); + JsonElement secondLink = second.RootElement.GetProperty("links")[0]; + Assert.That(secondLink.GetProperty("rel").GetString(), Is.EqualTo("tm:extends")); + Assert.That(secondLink.GetProperty("hreflang").GetString(), Is.EqualTo("en")); + } + + [Test] + public void ResidueHonorsConfiguredDepthAboveFrameworkDefault() + { + const int depth = 70; + var nested = new StringBuilder(); + for (int ii = 0; ii < depth; ii++) + { + nested.Append("{\"next\":"); + } + nested.Append("\"leaf\""); + for (int ii = 0; ii < depth; ii++) + { + nested.Append('}'); + } + + string json = + "{" + + "\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"DeepType\",\"uav:browseName\":\"1:DeepType\"," + + "\"vendor:deep\":" + nested + "}"; + var options = new WotNodeSetConverterOptions { MaxJsonDepth = 96 }; + + using WotDocument source = WotDocument.Parse( + Encoding.UTF8.GetBytes(json), + options); + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(source, options); + using WotDocument restored = + WotNodeSetConverter.FromNodeSet(nodeSet, options: options); + + JsonElement current = restored.RootElement.GetProperty("vendor:deep"); + for (int ii = 0; ii < depth; ii++) + { + current = current.GetProperty("next"); + } + Assert.That(current.GetString(), Is.EqualTo("leaf")); + } + + [Test] + public void ResiduePointerBeyondConfiguredDepthProducesDiagnostic() + { + const string json = + "{" + + "\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"BoundedType\",\"uav:browseName\":\"1:BoundedType\"," + + "\"vendor:value\":1}"; + + UANodeSet nodeSet = + WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + System.Xml.XmlElement residue = nodeSet.Extensions!.Single(e => + e.LocalName == "WoTJsonResidue"); + System.Xml.XmlElement member = residue.ChildNodes + .OfType() + .Single(); + member.SetAttribute("Pointer", string.Concat(Enumerable.Repeat("/x", 12))); + + var options = new WotNodeSetConverterOptions { MaxJsonDepth = 8 }; + WotConversionResult result = null; + Assert.That( + () => result = WotNodeSetConverter.FromNodeSetResult( + nodeSet, + options: options), + Throws.Nothing); + using WotDocument document = result!.Value!; + Assert.That(result.HasErrors, Is.True); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.ResidueInvalid), + Is.True); + } + + [Test] + public void ResidueUsesSameAffordanceCollisionKeysAsReadableMapping() + { + const string json = + "{" + + "\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\",{" + + "\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"CollisionType\",\"uav:browseName\":\"1:CollisionType\"," + + "\"properties\":{" + + "\"first\":{\"uav:browseName\":\"1:Temp\",\"type\":\"number\"," + + "\"vendor:value\":\"first\"}," + + "\"second\":{\"uav:browseName\":\"2:Temp\",\"type\":\"number\"," + + "\"vendor:value\":\"second\"}}}"; + + UANodeSet nodeSet = + WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json)); + using WotDocument restored = WotNodeSetConverter.FromNodeSet(nodeSet); + + JsonElement properties = restored.RootElement.GetProperty("properties"); + Assert.That( + properties.GetProperty("Temp").GetProperty("vendor:value").GetString(), + Is.EqualTo("first")); + Assert.That( + properties.GetProperty("Temp_2").GetProperty("vendor:value").GetString(), + Is.EqualTo("second")); + } + + private static void AssertResidue(WotDocument document) + { + JsonElement root = document.RootElement; + Assert.That( + root.GetProperty("vendor:root").GetProperty("b").GetInt32(), + Is.EqualTo(2)); + + JsonElement speed = root + .GetProperty("properties") + .GetProperty("Speed"); + Assert.That( + speed.TryGetProperty("vendor:quality", out JsonElement quality), + Is.True, + Encoding.UTF8.GetString(document.Utf8Json.ToArray())); + Assert.That( + quality.GetProperty("mode").GetString(), + Is.EqualTo("good")); + Assert.That( + speed.GetProperty("forms")[0].GetProperty("op")[0].GetString(), + Is.EqualTo("readproperty")); + + JsonElement context = root.GetProperty("@context"); + Assert.That( + context[1].GetProperty("vendor").GetString(), + Is.EqualTo("urn:vendor:")); + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotNativeProjectionTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotNativeProjectionTests.cs new file mode 100644 index 0000000000..0db6abbf8a --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotNativeProjectionTests.cs @@ -0,0 +1,173 @@ +/* ======================================================================== + * 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.IO; +using System.Linq; +using System.Text.Json; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotNativeProjectionTests + { + [Test] + public void NativeProjectionReconstructsNodeSetWithoutEnvelope() + { + UANodeSet source = WotTestData.CreateReconstructableNodeSet(); + byte[] json = BuildNativeOnlyDocument(source); + + UANodeSet restored = WotNodeSetConverter.ToNodeSet(json); + + NodeSetComparisonResult comparison = NodeSetComparer.Compare(source, restored); + Assert.That( + comparison.AreEquivalent, + Is.True, + string.Join("; ", comparison.Differences)); + } + + [Test] + public void NativeReconstructionPreservesNodeClassesAndReferences() + { + UANodeSet source = WotTestData.CreateReconstructableNodeSet(); + byte[] json = BuildNativeOnlyDocument(source); + + UANodeSet restored = WotNodeSetConverter.ToNodeSet(json); + + Assert.That(restored.Items, Has.Length.EqualTo(3)); + UAVariable variable = restored.Items!.OfType().Single(); + Assert.That(variable.BrowseName, Is.EqualTo("1:PumpSpeed")); + Assert.That(variable.DataType, Is.EqualTo("Double")); + Assert.That(variable.AccessLevel, Is.EqualTo(3)); + Assert.That( + variable.References!.Any(r => + r.ReferenceType == "HasModellingRule" && r.Value == "i=78"), + Is.True); + + UAMethod method = restored.Items!.OfType().Single(); + Assert.That(method.BrowseName, Is.EqualTo("1:Reset")); + } + + [Test] + public void NativeReconstructionIsDeterministic() + { + UANodeSet source = WotTestData.CreateReconstructableNodeSet(); + byte[] json = BuildNativeOnlyDocument(source); + + UANodeSet first = WotNodeSetConverter.ToNodeSet(json); + UANodeSet second = WotNodeSetConverter.ToNodeSet(json); + + Assert.That(WotTestData.Serialize(first), Is.EqualTo(WotTestData.Serialize(second))); + } + + [Test] + public void NativeProjectionExposesDerivedTypeInformation() + { + UANodeSet source = WotTestData.CreateReconstructableNodeSet(); + using WotDocument document = WotNodeSetConverter.FromNodeSet( + source, + options: NativeOnly()); + JsonElement nodes = document.RootElement + .GetProperty("uav:nodes") + .GetProperty("nodes"); + + JsonElement variable = nodes.EnumerateArray() + .Single(n => n.GetProperty("nodeClass").GetString() == "Variable"); + Assert.That(variable.GetProperty("typeDefinition").GetString(), Is.EqualTo("i=63")); + Assert.That(variable.GetProperty("modellingRule").GetString(), Is.EqualTo("Mandatory")); + + JsonElement objectType = nodes.EnumerateArray() + .Single(n => n.GetProperty("nodeClass").GetString() == "ObjectType"); + Assert.That(objectType.GetProperty("superType").GetString(), Is.EqualTo("i=58")); + } + + [Test] + public void NativeReconstructionPreservesReferenceTypeInverseName() + { + UANodeSet source = WotTestData.CreateRichNodeSet(); + byte[] json = BuildNativeOnlyDocument(source); + + UANodeSet restored = WotNodeSetConverter.ToNodeSet(json); + + UAReferenceType referenceType = restored.Items!.OfType().Single(); + Assert.That(referenceType.BrowseName, Is.EqualTo("1:Controls")); + Assert.That(referenceType.Symmetric, Is.False); + // The InverseName must be restored exactly, not silently dropped. + Assert.That(referenceType.InverseName, Is.Not.Null, + "A non-symmetric ReferenceType must retain its InverseName across the native projection."); + Assert.That(referenceType.InverseName!, Has.Length.EqualTo(1)); + Assert.That(referenceType.InverseName[0].Value, Is.EqualTo("IsControlledBy")); + } + + [Test] + public void NativeReconstructionPreservesLocalizedInverseName() + { + UANodeSet source = WotTestData.CreateRichNodeSet(); + UAReferenceType sourceReference = source.Items!.OfType().Single(); + // Exercise the localized-entry path: a locale plus a second entry. + sourceReference.InverseName = + [ + new Opc.Ua.Export.LocalizedText { Locale = "en", Value = "IsControlledBy" }, + new Opc.Ua.Export.LocalizedText { Locale = "de", Value = "WirdGesteuertVon" } + ]; + + byte[] json = BuildNativeOnlyDocument(source); + UANodeSet restored = WotNodeSetConverter.ToNodeSet(json); + + UAReferenceType referenceType = restored.Items!.OfType().Single(); + Assert.That(referenceType.InverseName!, Has.Length.EqualTo(2)); + Assert.That(referenceType.InverseName![0].Locale, Is.EqualTo("en")); + Assert.That(referenceType.InverseName[0].Value, Is.EqualTo("IsControlledBy")); + Assert.That(referenceType.InverseName[1].Locale, Is.EqualTo("de")); + Assert.That(referenceType.InverseName[1].Value, Is.EqualTo("WirdGesteuertVon")); + } + + private static byte[] BuildNativeOnlyDocument(UANodeSet source) + { + using WotDocument document = WotNodeSetConverter.FromNodeSet( + source, + options: NativeOnly()); + Assert.That(document.TryGetEnvelope(out _), Is.False); + return document.Utf8Json.ToArray(); + } + + private static WotNodeSetConverterOptions NativeOnly() + { + return new WotNodeSetConverterOptions + { + PreservationMode = WotNodeSetPreservationMode.Never + }; + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotNodeSetConverterTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotNodeSetConverterTests.cs new file mode 100644 index 0000000000..d7ccefc564 --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotNodeSetConverterTests.cs @@ -0,0 +1,179 @@ +/* ======================================================================== + * 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.IO; +using System.Text; +using System.Xml; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotNodeSetConverterTests + { + [Test] + public void PreservationEnvelopeRoundTripsCanonicalNodeSet() + { + UANodeSet source = CreateNodeSet(); + + using WotDocument document = WotNodeSetConverter.FromNodeSet( + source, + options: AlwaysPreserve()); + UANodeSet restored = WotNodeSetConverter.ToNodeSet(document); + + Assert.That(Write(restored), Is.EqualTo(Write(source))); + Assert.That( + document.RootElement.GetProperty("uav:nodeSet").GetProperty("encoding").GetString(), + Is.EqualTo("base64")); + } + + [Test] + public void GeneratedEnvelopeIsDeterministic() + { + UANodeSet source = CreateNodeSet(); + + using WotDocument first = WotNodeSetConverter.FromNodeSet( + source, + options: AlwaysPreserve()); + using WotDocument second = WotNodeSetConverter.FromNodeSet( + source, + options: AlwaysPreserve()); + + Assert.That(first.Utf8Json.ToArray(), Is.EqualTo(second.Utf8Json.ToArray())); + } + + [Test] + public void WotDocumentPreservesUnknownMembersLexically() + { + byte[] json = Encoding.UTF8.GetBytes( + "{\"@context\":[],\"title\":\"T\",\"vendor:unknown\":{\"b\":2,\"a\":1}}"); + + using WotDocument document = WotDocument.Parse(json); + using var output = new MemoryStream(); + document.Write(output); + + Assert.That(output.ToArray(), Is.EqualTo(json)); + } + + [Test] + public void DigestMismatchIsRejected() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet( + CreateNodeSet(), + options: AlwaysPreserve()); + string json = Encoding.UTF8.GetString(document.Utf8Json.ToArray()); + const string marker = "\"data\": \""; + int valueIndex = json.IndexOf(marker, StringComparison.Ordinal) + marker.Length; + char[] characters = json.ToCharArray(); + characters[valueIndex] = characters[valueIndex] == 'A' ? 'B' : 'A'; + json = new string(characters); + + Assert.Throws( + () => WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(json))); + } + + private static UANodeSet CreateNodeSet() + { + var xml = new XmlDocument(); + System.Xml.XmlElement extension = xml.CreateElement("test", "Metadata", "urn:test"); + extension.SetAttribute("key", "value"); + extension.InnerText = "payload"; + + return new UANodeSet + { + NamespaceUris = ["urn:test:model"], + Models = + [ + new ModelTableEntry + { + ModelUri = "urn:test:model", + Version = "1.0.0", + PublicationDate = new DateTime(2026, 7, 20, 0, 0, 0, DateTimeKind.Utc), + PublicationDateSpecified = true + } + ], + Extensions = [extension], + Items = + [ + new UAObjectType + { + NodeId = "ns=1;i=1001", + BrowseName = "1:MachineType", + SymbolicName = "MachineType", + DisplayName = + [ + new Opc.Ua.Export.LocalizedText + { + Value = "MachineType" + } + ], + Description = + [ + new Opc.Ua.Export.LocalizedText + { + Locale = "en", + Value = "A test type." + } + ], + References = + [ + new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + Value = "i=58" + } + ] + } + ] + }; + } + + private static WotNodeSetConverterOptions AlwaysPreserve() + { + return new WotNodeSetConverterOptions + { + PreservationMode = WotNodeSetPreservationMode.Always + }; + } + + private static byte[] Write(UANodeSet nodeSet) + { + using var stream = new MemoryStream(); + nodeSet.Write(stream); + return stream.ToArray(); + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotResolverAndBoundsTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotResolverAndBoundsTests.cs new file mode 100644 index 0000000000..b4ac50e7cd --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotResolverAndBoundsTests.cs @@ -0,0 +1,346 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Text.Json; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotResolverAndBoundsTests + { + [Test] + public void ResolutionContextDetectsCycles() + { + var context = new WotResolutionContext(); + + Assert.That(context.TryEnter(WotResolutionKind.Thing, "urn:a", out _), Is.True); + Assert.That(context.TryEnter(WotResolutionKind.Thing, "urn:a", out var diagnostic), Is.False); + Assert.That(diagnostic!.Code, Is.EqualTo(WotDiagnosticCode.ResolverCycle)); + } + + [Test] + public void ResolutionContextEnforcesDepthLimit() + { + var context = new WotResolutionContext(new WotResolverOptions { MaxDepth = 1 }); + + Assert.That(context.TryEnter(WotResolutionKind.Thing, "urn:a", out _), Is.True); + Assert.That(context.TryEnter(WotResolutionKind.Thing, "urn:b", out var diagnostic), Is.False); + Assert.That(diagnostic!.Code, Is.EqualTo(WotDiagnosticCode.ResolverDepthExceeded)); + } + + [Test] + public void ResolutionContextEnforcesDocumentAndByteLimits() + { + var context = new WotResolutionContext( + new WotResolverOptions { MaxDocuments = 1, MaxDepth = 10, MaxDocumentBytes = 5 }); + + Assert.That(context.TryEnter(WotResolutionKind.Thing, "urn:a", out _), Is.True); + Assert.That(context.TryAddBytes("urn:a", 10, out var byteLimit), Is.False); + Assert.That(byteLimit!.Code, Is.EqualTo(WotDiagnosticCode.ResolverLimitExceeded)); + + context.Leave("urn:a"); + Assert.That(context.TryEnter(WotResolutionKind.Thing, "urn:b", out var documentLimit), Is.False); + Assert.That(documentLimit!.Code, Is.EqualTo(WotDiagnosticCode.ResolverLimitExceeded)); + } + + [Test] + public void NullResolverNeverResolves() + { + var context = new WotResolutionContext(); + WotResolverResult result = NullWotResolver.Instance.ResolveThing("urn:a", context); + Assert.That(result.Found, Is.False); + } + + [Test] + public void ResolverDrivenLinkResolutionFollowsRedirect() + { + var resolver = new MapResolver(new Dictionary(StringComparer.Ordinal) + { + ["urn:a"] = "{\"uav:congruentType\":\"urn:b\"}", + ["urn:b"] = "{\"uav:id\":\"ns=2;i=99\"}" + }); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(LinkModel("urn:a"))); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult( + document, null, resolver); + + UAObjectType root = result.Value!.Items!.OfType().Single(); + Assert.That(root.References!.Any(r => r.Value == "ns=2;i=99"), Is.True); + } + + [Test] + public void ResolverDrivenLinkResolutionDetectsCycle() + { + var resolver = new MapResolver(new Dictionary(StringComparer.Ordinal) + { + ["urn:a"] = "{\"uav:congruentType\":\"urn:b\"}", + ["urn:b"] = "{\"uav:congruentType\":\"urn:a\"}" + }); + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(LinkModel("urn:a"))); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult( + document, null, resolver, new WotResolutionContext()); + + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.ResolverCycle), + Is.True); + } + + [Test] + public void OneResolutionContextIsCreatedPerTopLevelConversionNotPerLink() + { + // Regression test: TryResolveTargetNodeId used to fall back to + // `new WotResolutionContext()` whenever it was handed a null + // context, and that fallback ran once per resolved link. With + // three sibling links sharing the same conversion, a document + // budget of two must therefore be exhausted by the third link, + // proving all links share one context seeded up front rather than + // each silently getting a fresh, unbounded context of its own. + var resolver = new MapResolver(new Dictionary(StringComparer.Ordinal) + { + ["urn:a"] = "{\"uav:id\":\"ns=2;i=101\"}", + ["urn:b"] = "{\"uav:id\":\"ns=2;i=102\"}", + ["urn:c"] = "{\"uav:id\":\"ns=2;i=103\"}" + }); + var options = new WotNodeSetConverterOptions { MaxResolverDocuments = 2 }; + + using WotDocument document = WotDocument.Parse( + Encoding.UTF8.GetBytes(MultiLinkModel("urn:a", "urn:b", "urn:c"))); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document, options, resolver); + + UAObjectType root = result.Value!.Items!.OfType().Single(); + Assert.That( + root.References!.Count(r => r.Value is "ns=2;i=101" or "ns=2;i=102"), + Is.EqualTo(2)); + Assert.That(root.References!.Any(r => r.Value == "ns=2;i=103"), Is.False); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.ResolverLimitExceeded), + Is.True); + } + + [Test] + public void MultipleLinksAccumulateAggregateByteLimitAcrossTheSameConversion() + { + // Both resolved documents are 23 bytes; a 30 byte total budget + // allows the first but must reject the second. If a fresh + // context were created per link (the bug this guards against), + // both would fit under the budget individually and no diagnostic + // would ever be produced. + var resolver = new MapResolver(new Dictionary(StringComparer.Ordinal) + { + ["urn:a"] = "{\"uav:id\":\"ns=2;i=101\"}", + ["urn:b"] = "{\"uav:id\":\"ns=2;i=102\"}" + }); + var options = new WotNodeSetConverterOptions { MaxResolverTotalBytes = 30 }; + + using WotDocument document = WotDocument.Parse( + Encoding.UTF8.GetBytes(MultiLinkModel("urn:a", "urn:b"))); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document, options, resolver); + + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.ResolverLimitExceeded), + Is.True); + } + + [Test] + public void SiblingLinkCycleDoesNotBlockAnUnrelatedSiblingLinkButIsStillReported() + { + var resolver = new MapResolver(new Dictionary(StringComparer.Ordinal) + { + ["urn:ok"] = "{\"uav:id\":\"ns=2;i=201\"}", + ["urn:cyclic-a"] = "{\"uav:congruentType\":\"urn:cyclic-b\"}", + ["urn:cyclic-b"] = "{\"uav:congruentType\":\"urn:cyclic-a\"}" + }); + + using WotDocument document = WotDocument.Parse( + Encoding.UTF8.GetBytes(MultiLinkModel("urn:ok", "urn:cyclic-a"))); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document, null, resolver); + + UAObjectType root = result.Value!.Items!.OfType().Single(); + Assert.That(root.References!.Any(r => r.Value == "ns=2;i=201"), Is.True); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.ResolverCycle), + Is.True); + } + + [Test] + public void ConverterOptionsProjectToMatchingResolverOptions() + { + var options = new WotNodeSetConverterOptions + { + MaxResolverDepth = 3, + MaxResolverDocuments = 4, + MaxResolverDocumentBytes = 5, + MaxResolverTotalBytes = 6 + }; + + WotResolverOptions resolverOptions = options.ToResolverOptions(); + + Assert.That(resolverOptions.MaxDepth, Is.EqualTo(3)); + Assert.That(resolverOptions.MaxDocuments, Is.EqualTo(4)); + Assert.That(resolverOptions.MaxDocumentBytes, Is.EqualTo(5)); + Assert.That(resolverOptions.MaxTotalBytes, Is.EqualTo(6)); + } + + [Test] + public void OptionsValidateRejectsNonPositiveResolverLimits() + { + Assert.That( + () => new WotNodeSetConverterOptions { MaxResolverDocuments = 0 }.Validate(), + Throws.TypeOf()); + Assert.That( + () => new WotNodeSetConverterOptions { MaxResolverDocumentBytes = 0 }.Validate(), + Throws.TypeOf()); + Assert.That( + () => new WotNodeSetConverterOptions { MaxResolverTotalBytes = 0 }.Validate(), + Throws.TypeOf()); + } + + [Test] + public void OptionsValidateRejectsNonPositiveLimits() + { + var options = new WotNodeSetConverterOptions { MaxJsonDepth = 0 }; + Assert.That(() => options.Validate(), Throws.TypeOf()); + } + + [Test] + public void ParseRejectsOversizedDocuments() + { + var options = new WotNodeSetConverterOptions { MaxJsonDocumentSize = 8 }; + byte[] json = Encoding.UTF8.GetBytes("{\"title\":\"a rather long value\"}"); + + Assert.That( + () => WotDocument.Parse(json, options), + Throws.TypeOf()); + } + + [Test] + public void ParseEnforcesDepthLimit() + { + var options = new WotNodeSetConverterOptions { MaxJsonDepth = 2 }; + byte[] json = Encoding.UTF8.GetBytes("{\"a\":{\"b\":{\"c\":1}}}"); + + Assert.That( + () => WotDocument.Parse(json, options), + Throws.InstanceOf()); + } + + [Test] + public void MalformedJsonThrows() + { + Assert.That( + () => WotDocument.Parse(Encoding.UTF8.GetBytes("{ not json")), + Throws.InstanceOf()); + } + + [Test] + public void InvalidBase64EnvelopeIsReported() + { + const string json = + "{\"@type\":\"tm:ThingModel\",\"uav:nodeSet\":{" + + "\"@type\":\"uav:nodeSet\",\"contentType\":\"application/opcua-nodeset+xml\"," + + "\"encoding\":\"base64\",\"data\":\"not*valid*base64\"}}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(json)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.Value, Is.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.InvalidBase64), + Is.True); + } + + [Test] + public void DecodedNodeSetExceedingLimitIsReported() + { + using WotDocument document = WotNodeSetConverter.FromNodeSet( + WotTestData.CreateReconstructableNodeSet(), + options: new WotNodeSetConverterOptions + { + PreservationMode = WotNodeSetPreservationMode.Always + }); + var options = new WotNodeSetConverterOptions { MaxNodeSetSize = 16 }; + + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document, options); + + Assert.That(result.Value, Is.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.NodeSetTooLarge), + Is.True); + } + + private static string LinkModel(string href) + { + return MultiLinkModel(href); + } + + private static string MultiLinkModel(params string[] hrefs) + { + string links = string.Join( + ",", + hrefs.Select(href => + "{\"rel\":\"ua:HasComponent\",\"href\":\"" + href + + "\",\"uav:refId\":\"i=47\"}")); + return + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\"," + + "{\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"," + + "\"ua\":\"http://opcfoundation.org/UA/\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"T\",\"uav:browseName\":\"1:T\"," + + "\"links\":[" + links + "]}"; + } + + private sealed class MapResolver : IWotThingResolver + { + private readonly Dictionary m_map; + + public MapResolver(Dictionary map) + { + m_map = map; + } + + public WotResolverResult ResolveThing(string reference, WotResolutionContext context) + { + return m_map.TryGetValue(reference, out var json) + ? WotResolverResult.FromBytes(Encoding.UTF8.GetBytes(json)) + : WotResolverResult.NotFound; + } + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotSynthesisTests.cs b/tests/Opc.Ua.Types.Tests/Wot/WotSynthesisTests.cs new file mode 100644 index 0000000000..26a6c0b8a5 --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotSynthesisTests.cs @@ -0,0 +1,161 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using NUnit.Framework; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.Types.Tests.Wot +{ + [TestFixture] + [Category("WoT")] + [Parallelizable] + public class WotSynthesisTests + { + private const string ThingModel = + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\"," + + "{\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"}]," + + "\"@type\":[\"tm:ThingModel\",\"uav:objectType\"]," + + "\"title\":\"PumpType\",\"uav:browseName\":\"1:PumpType\"," + + "\"uav:id\":\"nsu=http://example.com/demo/pump;i=1001\"," + + "\"properties\":{\"pumpSpeed\":{\"@type\":\"uav:variableType\"," + + "\"uav:browseName\":\"1:PumpSpeed\",\"type\":\"number\"," + + "\"uav:modellingRule\":\"Mandatory\",\"readOnly\":true}}," + + "\"actions\":{\"reset\":{\"@type\":\"uav:method\"," + + "\"uav:browseName\":\"1:Reset\",\"uav:modellingRule\":\"Optional\"}}," + + "\"events\":{\"overTemp\":{\"uav:isEvent\":true,\"uav:browseName\":\"1:OverTemp\"}}}"; + + private const string ThingDescription = + "{\"@context\":[\"https://www.w3.org/2022/wot/td/v1.1\"," + + "{\"uav\":\"http://opcfoundation.org/UA/WoT-Binding/\"}]," + + "\"@type\":\"uav:object\",\"title\":\"Pump01\"," + + "\"uav:browseName\":\"1:Pump\"," + + "\"properties\":{\"speed\":{\"@type\":\"uav:variable\"," + + "\"uav:browseName\":\"1:Speed\",\"type\":\"number\",\"readOnly\":true}}}"; + + [Test] + public void ThingModelSynthesizesObjectTypeWithMembers() + { + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(ThingModel)); + + Assert.That(nodeSet.Models, Is.Not.Null); + Assert.That(nodeSet.Models![0].ModelUri, Is.EqualTo("http://example.com/demo/pump")); + + UAObjectType root = nodeSet.Items!.OfType() + .Single(t => t.BrowseName == "1:PumpType"); + Assert.That(root.NodeId, Is.EqualTo("ns=1;i=1001")); + Assert.That( + root.References!.Any(r => r.ReferenceType == "HasSubtype" && !r.IsForward && r.Value == "i=58"), + Is.True); + Assert.That( + root.References!.Any(r => r.ReferenceType == "HasComponent" && r.IsForward && r.Value == "ns=1;s=PumpType/PumpSpeed"), + Is.True); + Assert.That( + root.References!.Any(r => r.ReferenceType == "GeneratesEvent" && r.IsForward), + Is.True); + + UAVariable variable = nodeSet.Items!.OfType().Single(); + Assert.That(variable.NodeId, Is.EqualTo("ns=1;s=PumpType/PumpSpeed")); + Assert.That(variable.DataType, Is.EqualTo("i=11")); + Assert.That(variable.AccessLevel, Is.EqualTo(1)); + Assert.That( + variable.References!.Any(r => r.ReferenceType == "HasModellingRule" && r.Value == "i=78"), + Is.True); + + UAMethod method = nodeSet.Items!.OfType().Single(); + Assert.That(method.NodeId, Is.EqualTo("ns=1;s=PumpType/Reset")); + Assert.That( + method.References!.Any(r => r.ReferenceType == "HasModellingRule" && r.Value == "i=80"), + Is.True); + + UAObjectType eventType = nodeSet.Items!.OfType() + .Single(t => t.BrowseName == "1:OverTemp"); + Assert.That( + eventType.References!.Any(r => r.ReferenceType == "HasSubtype" && !r.IsForward && r.Value == "i=2041"), + Is.True); + } + + [Test] + public void ThingDescriptionSynthesizesObjectWithTypeDefinition() + { + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(ThingDescription)); + + UAObject root = nodeSet.Items!.OfType().Single(); + Assert.That(root.NodeId, Is.EqualTo("ns=1;s=Pump")); + Assert.That( + root.References!.Any(r => r.ReferenceType == "HasTypeDefinition" && r.IsForward && r.Value == "i=58"), + Is.True); + + UAVariable variable = nodeSet.Items!.OfType().Single(); + Assert.That(variable.NodeId, Is.EqualTo("ns=1;s=Pump/Speed")); + Assert.That(variable.AccessLevel, Is.EqualTo(1)); + } + + [Test] + public void SynthesisIsDeterministic() + { + UANodeSet first = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(ThingModel)); + UANodeSet second = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(ThingModel)); + + Assert.That(WotTestData.Serialize(first), Is.EqualTo(WotTestData.Serialize(second))); + } + + [Test] + public void UnsupportedSchemaProducesDiagnostic() + { + const string model = + "{\"@type\":[\"tm:ThingModel\",\"uav:objectType\"],\"title\":\"T\"," + + "\"uav:browseName\":\"1:T\",\"properties\":{\"blob\":{" + + "\"@type\":\"uav:variableType\",\"uav:browseName\":\"1:Blob\"," + + "\"uav:externalSchema\":\"https://example.com/schema.json\"}}}"; + + using WotDocument document = WotDocument.Parse(Encoding.UTF8.GetBytes(model)); + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult(document); + + Assert.That(result.Value, Is.Not.Null); + Assert.That( + result.Diagnostics.Any(d => d.Code == WotDiagnosticCode.UnsupportedSchema), + Is.True); + } + + [Test] + public void SynthesizedNodeSetSerializesToValidXml() + { + UANodeSet nodeSet = WotNodeSetConverter.ToNodeSet(Encoding.UTF8.GetBytes(ThingModel)); + byte[] xml = WotTestData.Serialize(nodeSet); + + using var stream = new System.IO.MemoryStream(xml); + bool valid = UANodeSet.Validate(stream, out System.Collections.Generic.IReadOnlyList errors); + Assert.That(valid, Is.True, string.Join("; ", errors)); + } + } +} diff --git a/tests/Opc.Ua.Types.Tests/Wot/WotTestData.cs b/tests/Opc.Ua.Types.Tests/Wot/WotTestData.cs new file mode 100644 index 0000000000..b566d1e86b --- /dev/null +++ b/tests/Opc.Ua.Types.Tests/Wot/WotTestData.cs @@ -0,0 +1,419 @@ +/* ======================================================================== + * 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.IO; +using System.Text; +using System.Xml; +using Opc.Ua.Export; + +namespace Opc.Ua.Types.Tests.Wot +{ + /// + /// Shared NodeSet2 fixtures for the WoT conversion tests. + /// + internal static class WotTestData + { + /// + /// Builds a NodeSet exercising several NodeClasses, references, + /// modelling rules, a NodeSet-level extension and a node-level extension. + /// + public static UANodeSet CreateRichNodeSet() + { + var xml = new XmlDocument(); + System.Xml.XmlElement modelExtension = xml.CreateElement("test", "Metadata", "urn:test"); + modelExtension.SetAttribute("key", "value"); + modelExtension.InnerText = "payload"; + + var nodeExtensionDoc = new XmlDocument(); + System.Xml.XmlElement nodeExtension = nodeExtensionDoc.CreateElement("vendor", "Note", "urn:vendor"); + nodeExtension.InnerText = "annotation"; + + var valueDocument = new XmlDocument(); + System.Xml.XmlElement variableValue = valueDocument.CreateElement( + "uax", + "Double", + Namespaces.OpcUaXsd); + variableValue.InnerText = "42.5"; + + var variableTypeValueDocument = new XmlDocument(); + System.Xml.XmlElement variableTypeValue = variableTypeValueDocument.CreateElement( + "uax", + "String", + Namespaces.OpcUaXsd); + variableTypeValue.InnerText = "default"; + + return new UANodeSet + { + NamespaceUris = ["urn:test:model"], + ServerUris = ["urn:test:server"], + Models = + [ + new ModelTableEntry + { + ModelUri = "urn:test:model", + XmlSchemaUri = "urn:test:model:schema", + Version = "1.0.0", + ModelVersion = "1.0.0+build.7", + AccessRestrictions = 3, + PublicationDate = new DateTime(2026, 7, 20, 0, 0, 0, DateTimeKind.Utc), + PublicationDateSpecified = true, + RolePermissions = + [ + new RolePermission { Value = "i=15644", Permissions = 65 } + ], + RequiredModel = + [ + new ModelTableEntry + { + ModelUri = "http://opcfoundation.org/UA/", + Version = "1.05.03", + PublicationDate = new DateTime(2023, 12, 15, 0, 0, 0, DateTimeKind.Utc), + PublicationDateSpecified = true + } + ] + }, + new ModelTableEntry + { + ModelUri = "urn:test:model:secondary", + Version = "1.0.0" + } + ], + Aliases = + [ + new NodeIdAlias { Alias = "MachineTypeAlias", Value = "ns=1;i=1001" } + ], + Extensions = [modelExtension], + LastModified = new DateTime(2026, 7, 21, 12, 34, 56, DateTimeKind.Utc), + LastModifiedSpecified = true, + Items = + [ + new UAObjectType + { + NodeId = "ns=1;i=1001", + BrowseName = "1:MachineType", + SymbolicName = "MachineType", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "MachineType" }], + Description = [new Opc.Ua.Export.LocalizedText { Locale = "en", Value = "A test type." }], + Category = ["Test", "Machine"], + Documentation = "https://example.test/MachineType", + WriteMask = 1, + UserWriteMask = 2, + AccessRestrictions = 3, + AccessRestrictionsSpecified = true, + RolePermissions = + [ + new RolePermission { Value = "i=15644", Permissions = 1 } + ], + ReleaseStatus = ReleaseStatus.Draft, + Extensions = [nodeExtension], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=58" }, + new Reference { ReferenceType = "HasComponent", IsForward = true, Value = "ns=1;i=6001" }, + new Reference { ReferenceType = "HasComponent", IsForward = true, Value = "ns=1;i=7001" }, + new Reference { ReferenceType = "GeneratesEvent", IsForward = true, Value = "ns=1;i=1002" } + ] + }, + new UAObjectType + { + NodeId = "ns=1;i=1002", + BrowseName = "1:OverTemperatureEventType", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "OverTemperatureEventType" }], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=2041" } + ] + }, + new UAVariable + { + NodeId = "ns=1;i=6001", + BrowseName = "1:Speed", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "Speed" }], + DataType = "Double", + AccessLevel = 3, + UserAccessLevel = 2, + MinimumSamplingInterval = 125.5, + Historizing = true, + DesignToolOnly = true, + Value = variableValue, + Translation = + [ + new TranslationType + { + Items = + [ + new Opc.Ua.Export.LocalizedText + { + Locale = "en", + Value = "Speed" + }, + new StructureTranslationType + { + Name = "Value", + Text = + [ + new Opc.Ua.Export.LocalizedText + { + Locale = "de", + Value = "Drehzahl" + } + ] + } + ] + } + ], + ParentNodeId = "ns=1;i=1001", + References = + [ + new Reference { ReferenceType = "HasTypeDefinition", IsForward = true, Value = "i=63" }, + new Reference { ReferenceType = "HasModellingRule", IsForward = true, Value = "i=78" }, + new Reference { ReferenceType = "HasComponent", IsForward = false, Value = "ns=1;i=1001" } + ] + }, + new UAMethod + { + NodeId = "ns=1;i=7001", + BrowseName = "1:Reset", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "Reset" }], + ParentNodeId = "ns=1;i=1001", + Executable = false, + UserExecutable = false, + MethodDeclarationId = "ns=1;i=7000", + ArgumentDescription = + [ + new UAMethodArgument + { + Name = "Reason", + Description = + [ + new Opc.Ua.Export.LocalizedText + { + Locale = "en", + Value = "Reset reason" + } + ] + } + ], + References = + [ + new Reference { ReferenceType = "HasModellingRule", IsForward = true, Value = "i=80" }, + new Reference { ReferenceType = "HasComponent", IsForward = false, Value = "ns=1;i=1001" } + ] + }, + new UAObject + { + NodeId = "ns=1;i=5001", + BrowseName = "1:Machine", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "Machine" }], + EventNotifier = 1, + References = + [ + new Reference { ReferenceType = "HasTypeDefinition", IsForward = true, Value = "ns=1;i=1001" } + ] + }, + new UAReferenceType + { + NodeId = "ns=1;i=4001", + BrowseName = "1:Controls", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "Controls" }], + InverseName = [new Opc.Ua.Export.LocalizedText { Value = "IsControlledBy" }], + Symmetric = false, + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=47" } + ] + }, + new UAVariableType + { + NodeId = "ns=1;i=3001", + BrowseName = "1:ConfiguredStringType", + DisplayName = + [ + new Opc.Ua.Export.LocalizedText { Value = "ConfiguredStringType" } + ], + IsAbstract = true, + DataType = "String", + ValueRank = 1, + ArrayDimensions = "4", + Value = variableTypeValue, + References = + [ + new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + Value = "i=62" + } + ] + }, + new UADataType + { + NodeId = "ns=1;i=3002", + BrowseName = "1:MachineMode", + DisplayName = + [ + new Opc.Ua.Export.LocalizedText { Value = "MachineMode" } + ], + Purpose = DataTypePurpose.CodeGenerator, + Definition = new Opc.Ua.Export.DataTypeDefinition + { + Name = "1:MachineMode", + SymbolicName = "MachineMode", + IsOptionSet = true, + Field = + [ + new Opc.Ua.Export.DataTypeField + { + Name = "Stopped", + SymbolicName = "Stopped", + Value = 0, + DisplayName = + [ + new Opc.Ua.Export.LocalizedText + { + Locale = "en", + Value = "Stopped" + } + ] + }, + new Opc.Ua.Export.DataTypeField + { + Name = "Running", + SymbolicName = "Running", + Value = 1, + IsOptional = true, + AllowSubTypes = true, + DataType = "i=6", + ValueRank = 1, + ArrayDimensions = "2", + MaxStringLength = 32 + } + ] + }, + References = + [ + new Reference + { + ReferenceType = "HasSubtype", + IsForward = false, + Value = "i=29" + } + ] + }, + new UAView + { + NodeId = "ns=1;i=8001", + BrowseName = "1:PlantView", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "PlantView" }], + ContainsNoLoops = true, + EventNotifier = 1 + } + ] + }; + } + + /// + /// Builds a compact NodeSet with a single ObjectType, one variable and + /// one method used by the native-projection reconstruction tests. + /// + public static UANodeSet CreateReconstructableNodeSet() + { + return new UANodeSet + { + NamespaceUris = ["urn:test:model"], + Models = + [ + new ModelTableEntry + { + ModelUri = "urn:test:model", + Version = "2.0.0", + PublicationDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), + PublicationDateSpecified = true + } + ], + Items = + [ + new UAObjectType + { + NodeId = "ns=1;i=1001", + BrowseName = "1:PumpType", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "PumpType" }], + References = + [ + new Reference { ReferenceType = "HasSubtype", IsForward = false, Value = "i=58" }, + new Reference { ReferenceType = "HasComponent", IsForward = true, Value = "ns=1;i=6001" }, + new Reference { ReferenceType = "HasComponent", IsForward = true, Value = "ns=1;i=7001" } + ] + }, + new UAVariable + { + NodeId = "ns=1;i=6001", + BrowseName = "1:PumpSpeed", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "PumpSpeed" }], + DataType = "Double", + AccessLevel = 3, + ParentNodeId = "ns=1;i=1001", + References = + [ + new Reference { ReferenceType = "HasTypeDefinition", IsForward = true, Value = "i=63" }, + new Reference { ReferenceType = "HasModellingRule", IsForward = true, Value = "i=78" }, + new Reference { ReferenceType = "HasComponent", IsForward = false, Value = "ns=1;i=1001" } + ] + }, + new UAMethod + { + NodeId = "ns=1;i=7001", + BrowseName = "1:Reset", + DisplayName = [new Opc.Ua.Export.LocalizedText { Value = "Reset" }], + ParentNodeId = "ns=1;i=1001", + References = + [ + new Reference { ReferenceType = "HasModellingRule", IsForward = true, Value = "i=80" }, + new Reference { ReferenceType = "HasComponent", IsForward = false, Value = "ns=1;i=1001" } + ] + } + ] + }; + } + + public static byte[] Serialize(UANodeSet nodeSet) + { + using var stream = new MemoryStream(); + nodeSet.Write(stream); + return stream.ToArray(); + } + + public static byte[] Utf8(string text) + { + return Encoding.UTF8.GetBytes(text); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/ExecutorUnitTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/ExecutorUnitTests.cs new file mode 100644 index 0000000000..0e76fbb9ff --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/ExecutorUnitTests.cs @@ -0,0 +1,115 @@ +/* ======================================================================== + * 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.Immutable; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Http; +using Opc.Ua.WotCon.Binding.Modbus; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Tests.Support; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// Unit tests for executor identity, dispatch and HTTP error mapping. + [TestFixture] + public sealed class ExecutorUnitTests + { + private static WotCompiledForm Compiled(string bindingId, string scheme) + => new WotCompiledForm( + new WotBindingIdentity(bindingId, "1.0", "urn:x"), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor(scheme, "h", 1, scheme + "://h"), + new WotAddressingDescriptor("t"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + ImmutableArray.Empty, isExecutable: true); + + [Test] + public void CanExecute_MatchesOwnBindingOnly() + { + var http = new HttpWotBindingExecutor(); + var modbus = new ModbusWotBindingExecutor(); + + Assert.That(http.CanExecute(Compiled("w3c.http", "https")), Is.True); + Assert.That(http.CanExecute(Compiled("w3c.modbus", "modbus+tcp")), Is.False); + Assert.That(modbus.CanExecute(Compiled("w3c.modbus", "modbus+tcp")), Is.True); + Assert.That(modbus.CanExecute(Compiled("w3c.http", "https")), Is.False); + } + + [Test] + public void Executors_IdentifyTheirPlannerBinding() + { + Assert.That(new HttpWotBindingExecutor().Identity.Id, Is.EqualTo(new HttpBindingPlanner().Identity.Id)); + Assert.That(new ModbusWotBindingExecutor().Identity.Id, Is.EqualTo(new ModbusBindingPlanner().Identity.Id)); + } + + [Test] + public async Task Http_ErrorStatusMapping() + { + (int Http, StatusCode Expected)[] cases = + { + (400, StatusCodes.BadInvalidArgument), + (401, StatusCodes.BadUserAccessDenied), + (404, StatusCodes.BadNodeIdUnknown), + (500, StatusCodes.BadInternalError) + }; + + foreach ((int http, StatusCode expected) in cases) + { + using var server = new TestHttpServer((method, path, body) => + new TestHttpResponse(http, "application/json", Encoding.UTF8.GetBytes("\"x\""))); + + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] { new HttpWotBindingExecutor( + new HttpWotBindingOptions { ClientFactory = () => new HttpClient() }) }); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + server.BaseUrl + "/p\"}]}}}"; + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + WotCompiledForm read = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Status, Is.EqualTo(expected), $"HTTP {http} mapping."); + } + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/HttpCredentialResolutionTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/HttpCredentialResolutionTests.cs new file mode 100644 index 0000000000..750ee1e89b --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/HttpCredentialResolutionTests.cs @@ -0,0 +1,186 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Immutable; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Http; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Tests.Support; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// Tests that HTTP credential resolution is race-free: concurrent requests on + /// a single channel resolve the credential exactly once and never send a + /// request before the credential is applied, and a failed resolution is + /// retried on the next request. + /// + [TestFixture] + public sealed class HttpCredentialResolutionTests + { + private const string SecuredTd = + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"securityDefinitions\":{\"apikey_sc\":{\"scheme\":\"apikey\",\"in\":\"query\",\"name\":\"token\"}}," + + "\"security\":\"apikey_sc\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"{BASE}/p\"}]}}}"; + + private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string baseUrl) + { + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, + Encoding.UTF8.GetBytes(SecuredTd.Replace("{BASE}", baseUrl, StringComparison.Ordinal)))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + } + + [Test] + public async Task ConcurrentRequests_NeverSendUnauthenticated_AndResolveOnce() + { + var authenticated = new ConcurrentQueue(); + using var server = new TestHttpServer((method, path, body) => + { + // The resolved API key is carried as a query parameter, so an + // authenticated request has "token=secret" in its target. + authenticated.Enqueue(path.Contains("token=secret", StringComparison.Ordinal)); + return TestHttpResponse.Json(200, "1"); + }); + + var credentials = new SlowQueryCredentialProvider(TimeSpan.FromMilliseconds(150)); + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] + { + new HttpWotBindingExecutor(new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(), + CallerClientHandlesRedirectSafety = true + }) + }, + credentials: credentials); + + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl)) + .ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Task[] reads = Enumerable.Range(0, 12) + .Select(_ => channel.ReadAsync().AsTask()) + .ToArray(); + WotReadResult[] results = await Task.WhenAll(reads).ConfigureAwait(false); + Assert.That(results.All(r => r.Success), Is.True, "Every concurrent read must succeed."); + } + + Assert.That(authenticated.Count, Is.EqualTo(12)); + Assert.That(authenticated.All(a => a), Is.True, + "No request may be sent before the credential is resolved and applied."); + Assert.That(credentials.ResolveCount, Is.EqualTo(1), + "The credential must be resolved exactly once and shared across concurrent requests."); + } + + [Test] + public async Task CredentialResolutionFailure_IsRetriedOnNextRequest() + { + using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(200, "1")); + + var credentials = new FailOnceCredentialProvider(); + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] + { + new HttpWotBindingExecutor(new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(), + CallerClientHandlesRedirectSafety = true + }) + }, + credentials: credentials); + + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl)) + .ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + // The first resolution faults and must surface, not be cached. + Assert.ThrowsAsync( + async () => await channel.ReadAsync().ConfigureAwait(false)); + + // The next request retries resolution and succeeds. + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True); + } + + Assert.That(credentials.ResolveCount, Is.EqualTo(2), + "A failed resolution must not be cached; the next request retries it."); + } + + private sealed class SlowQueryCredentialProvider : IWotCredentialProvider + { + public SlowQueryCredentialProvider(TimeSpan delay) => m_delay = delay; + + public int ResolveCount => Volatile.Read(ref m_resolveCount); + + public async ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref m_resolveCount); + await Task.Delay(m_delay, cancellationToken).ConfigureAwait(false); + return new WotCredential( + WotSecurityScheme.ApiKey, + queryParameters: ImmutableDictionary.Empty.Add("token", "secret")); + } + + private readonly TimeSpan m_delay; + private int m_resolveCount; + } + + private sealed class FailOnceCredentialProvider : IWotCredentialProvider + { + public int ResolveCount => Volatile.Read(ref m_resolveCount); + + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref m_resolveCount) == 1) + { + throw new InvalidOperationException("Transient credential resolution failure."); + } + return new ValueTask(new WotCredential( + WotSecurityScheme.ApiKey, + queryParameters: ImmutableDictionary.Empty.Add("token", "secret"))); + } + + private int m_resolveCount; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/HttpRedirectSecurityTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/HttpRedirectSecurityTests.cs new file mode 100644 index 0000000000..540048c75a --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/HttpRedirectSecurityTests.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * 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.Immutable; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Http; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Tests.Support; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// End-to-end tests for the HTTP executor's redirect-safe credential policy: + /// the executor-owned client disables automatic redirects and applies a + /// bounded, origin-aware redirect policy that drops custom header / query + /// credentials across origins, refuses loops and unsafe schemes, and honours a + /// redirect limit. A caller-supplied client with a credential-bearing form + /// fails closed unless the caller confirms safe redirect handling. + /// + [TestFixture] + public sealed class HttpRedirectSecurityTests + { + private const string SecuredTdTemplate = + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"securityDefinitions\":{\"apikey_sc\":{\"scheme\":\"apikey\",\"in\":\"query\",\"name\":\"token\"}}," + + "\"security\":\"apikey_sc\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"{HREF}\"}]}}}"; + + private static WotProtocolBinderRegistry OwnedRegistry( + IWotCredentialProvider? credentials = null, HttpWotBindingOptions? options = null) + => new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] { new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions()) }, + credentials: credentials); + + private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string href) + { + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, + Encoding.UTF8.GetBytes(SecuredTdTemplate.Replace("{HREF}", href, StringComparison.Ordinal)))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + } + + [Test] + public async Task CrossOriginRedirect_DropsHeaderAndQueryCredentials() + { + var origin = new Recorder(); + var target = new Recorder(); + using var targetServer = new TestHttpServer(request => + { + target.Record(request); + return TestHttpResponse.Json(200, "7"); + }); + using var originServer = new TestHttpServer(request => + { + origin.Record(request); + return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry(new HeaderQueryCredentialProvider()); + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, originServer.BaseUrl + "/p")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.True, "The read must follow the redirect and succeed."); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(7L)); + } + + Assert.Multiple(() => + { + Assert.That(origin.SawQueryToken, Is.True, "The origin request must carry the query credential."); + Assert.That(origin.SawHeaderToken, Is.True, "The origin request must carry the header credential."); + Assert.That(target.SawQueryToken, Is.False, + "A cross-origin redirect must not forward the query credential."); + Assert.That(target.SawHeaderToken, Is.False, + "A cross-origin redirect must not forward the header credential."); + }); + } + + [Test] + public async Task SameOriginRedirect_KeepsCredentials() + { + var recorder = new Recorder(); + using var server = new TestHttpServer(request => + { + recorder.Record(request); + if (request.Path.StartsWith("/a", StringComparison.Ordinal)) + { + return TestHttpResponse.Redirect("/b"); + } + return TestHttpResponse.Json(200, "9"); + }); + + WotProtocolBinderRegistry registry = OwnedRegistry(new HeaderQueryCredentialProvider()); + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl + "/a")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(9L)); + } + + Assert.That(recorder.PathsSeen.Any(p => p.StartsWith("/b", StringComparison.Ordinal) && + p.Contains("token=secret", StringComparison.Ordinal)), Is.True, + "A same-origin redirect must keep the query credential on the follow-up request."); + } + + [Test] + public async Task RedirectLoop_IsRejected() + { + using var server = new TestHttpServer(request => + request.Path.StartsWith("/a", StringComparison.Ordinal) + ? TestHttpResponse.Redirect("/b") + : TestHttpResponse.Redirect("/a")); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadFormNoSecurity(registry, server.BaseUrl + "/a")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Does.Contain("loop").IgnoreCase); + } + } + + [Test] + public async Task RedirectToDisallowedScheme_IsRejected() + { + using var server = new TestHttpServer(_ => TestHttpResponse.Redirect("ftp://evil.example.com/x")); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadFormNoSecurity(registry, server.BaseUrl + "/p")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo((StatusCode)StatusCodes.BadSecurityChecksFailed)); + } + } + + [Test] + public async Task RedirectLimit_IsEnforced() + { + int counter = 0; + using var server = new TestHttpServer(_ => + TestHttpResponse.Redirect("/r" + Interlocked.Increment(ref counter))); + + WotProtocolBinderRegistry registry = OwnedRegistry( + options: new HttpWotBindingOptions { MaxAutomaticRedirects = 2 }); + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadFormNoSecurity(registry, server.BaseUrl + "/start")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.False); + Assert.That(result.Error, Does.Contain("redirect limit").IgnoreCase); + } + } + + [Test] + public async Task OwnedClient_FollowsTemporaryRedirect_ToSuccess() + { + using var server = new TestHttpServer(request => + request.Path.StartsWith("/a", StringComparison.Ordinal) + ? TestHttpResponse.Redirect("/final", status: 307) + : TestHttpResponse.Json(200, "5")); + + WotProtocolBinderRegistry registry = OwnedRegistry(); + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadFormNoSecurity(registry, server.BaseUrl + "/a")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(5L)); + } + } + + [Test] + public void CallerSuppliedClient_WithCredentialForm_FailsClosed() + { + using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(200, "1")); + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] + { + new HttpWotBindingExecutor(new HttpWotBindingOptions { ClientFactory = () => new HttpClient() }) + }, + credentials: new HeaderQueryCredentialProvider()); + WotCompiledForm read = ReadForm(registry, server.BaseUrl + "/p"); + + Assert.ThrowsAsync( + async () => await registry.OpenChannelAsync(read).ConfigureAwait(false)); + } + + [Test] + public async Task CallerSuppliedClient_WithCredentialForm_AllowedWhenSafetyConfirmed() + { + using var server = new TestHttpServer(request => TestHttpResponse.Json(200, "1")); + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] + { + new HttpWotBindingExecutor(new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(new HttpClientHandler + { + AllowAutoRedirect = false, + CheckCertificateRevocationList = true + }), + CallerClientHandlesRedirectSafety = true + }) + }, + credentials: new HeaderQueryCredentialProvider()); + + IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl + "/p")); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.True); + } + } + + private static WotCompiledForm ReadFormNoSecurity(WotProtocolBinderRegistry registry, string href) + { + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + href + "\"}]}}}"; + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + } + + private sealed class Recorder + { + private readonly System.Collections.Concurrent.ConcurrentQueue m_paths = new(); + private int m_sawQueryToken; + private int m_sawHeaderToken; + + public bool SawQueryToken => Volatile.Read(ref m_sawQueryToken) != 0; + + public bool SawHeaderToken => Volatile.Read(ref m_sawHeaderToken) != 0; + + public System.Collections.Generic.IReadOnlyCollection PathsSeen => m_paths.ToArray(); + + public void Record(TestHttpRequest request) + { + m_paths.Enqueue(request.Path); + if (request.Path.Contains("token=secret", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref m_sawQueryToken, 1); + } + if (request.Headers.TryGetValue("X-Api-Key", out string? value) && + string.Equals(value, "secret", StringComparison.Ordinal)) + { + Interlocked.Exchange(ref m_sawHeaderToken, 1); + } + } + } + + private sealed class HeaderQueryCredentialProvider : IWotCredentialProvider + { + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + => new ValueTask(new WotCredential( + WotSecurityScheme.ApiKey, + headers: ImmutableDictionary.Empty.Add("X-Api-Key", "secret"), + queryParameters: ImmutableDictionary.Empty.Add("token", "secret"))); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/HttpWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/HttpWotExecutorTests.cs new file mode 100644 index 0000000000..52649c29cf --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/HttpWotExecutorTests.cs @@ -0,0 +1,203 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Http; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Tests.Support; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// End-to-end tests for the HTTP executor against an in-process HTTP server. + [TestFixture] + public sealed class HttpWotExecutorTests + { + private static WotProtocolBinderRegistry Registry(HttpWotBindingOptions? options = null) + => new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new HttpBindingPlanner() }, + new IWotBindingExecutor[] { new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient() + }) }); + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + => registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + + [Test] + public async Task Http_ReadWriteAction_EndToEnd() + { + var store = new ConcurrentDictionary(); + store["/prop"] = "10"; + using var server = new TestHttpServer((method, path, body) => + { + if (path == "/prop" && method == "GET") + { + return TestHttpResponse.Json(200, store.GetValueOrDefault("/prop", "0")); + } + if (path == "/prop" && method == "PUT") + { + store["/prop"] = Encoding.UTF8.GetString(body); + return new TestHttpResponse(204, "text/plain", Array.Empty()); + } + if (path == "/action" && method == "POST") + { + return TestHttpResponse.Json(200, "\"done\""); + } + return TestHttpResponse.Json(404, "\"missing\""); + }); + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + server.BaseUrl + "/prop\"}]}}," + + "\"actions\":{\"act\":{\"forms\":[{\"href\":\"" + server.BaseUrl + "/action\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + + WotCompiledForm read = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm invoke = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(10L)); + } + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync(new DataValue(new Variant(42L))); + Assert.That(result.Success, Is.True); + } + + IWotBindingChannel reread = await registry.OpenChannelAsync(read); + await using (reread.ConfigureAwait(false)) + { + WotReadResult result = await reread.ReadAsync(); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42L)); + } + + IWotBindingChannel actionChannel = await registry.OpenChannelAsync(invoke); + await using (actionChannel.ConfigureAwait(false)) + { + WotInvokeResult result = await actionChannel.InvokeAsync(Array.Empty()); + Assert.That(result.Success, Is.True); + Assert.That(result.Outputs.Count, Is.EqualTo(1)); + Assert.That(result.Outputs[0].WrappedValue.AsBoxedObject(), Is.EqualTo("done")); + } + } + + [Test] + public async Task Http_Observe_DeliversValueChanges() + { + var store = new ConcurrentDictionary(); + store["/prop"] = "1"; + using var server = new TestHttpServer((method, path, body) => + TestHttpResponse.Json(200, store.GetValueOrDefault("/prop", "0"))); + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"w\":{\"type\":\"number\",\"observable\":true,\"forms\":[{\"href\":\"" + + server.BaseUrl + "/prop\",\"op\":[\"observeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(new HttpWotBindingOptions + { + ClientFactory = () => new HttpClient(), + ObserveInterval = TimeSpan.FromMilliseconds(100) + }); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm observe = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await registry.OpenChannelAsync(observe); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel.ObserveAsync(n => + { + if (n.Value.WrappedValue.AsBoxedObject() is long value) + { + received.Enqueue(value); + } + }); + await using (subscription.ConfigureAwait(false)) + { + store["/prop"] = "99"; + Assert.That(await WaitForAsync(received, 99), Is.True, "The observe channel must deliver the change."); + } + } + } + + [Test] + public async Task Http_NotFound_MapsToBadNodeIdUnknown() + { + using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(404, "\"no\"")); + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + server.BaseUrl + "/x\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.False); + Assert.That(result.Status, Is.EqualTo((StatusCode)StatusCodes.BadNodeIdUnknown)); + } + } + + private static async Task WaitForAsync(ConcurrentQueue queue, long expected) + { + for (int i = 0; i < 50; i++) + { + if (queue.Contains(expected)) + { + return true; + } + await Task.Delay(50).ConfigureAwait(false); + } + return false; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/ModbusTcpClientHardeningTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/ModbusTcpClientHardeningTests.cs new file mode 100644 index 0000000000..28984425a2 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/ModbusTcpClientHardeningTests.cs @@ -0,0 +1,264 @@ +/* ======================================================================== + * 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.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding.Modbus; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// Hardening tests for : hostile / truncated + /// responses must map to a (never an out-of-range + /// index), and a timeout must fault the connection so a fresh reconnect is + /// required and works deterministically. + /// + [TestFixture] + public sealed class ModbusTcpClientHardeningTests + { + [Test] + public async Task TruncatedRegisterResponse_ThrowsModbusException() + { + // A register-read response whose declared byte count (4) exceeds the + // register bytes actually present in the frame. Before the bounds + // check this indexed out of range; now it maps to a ModbusException. + using var server = new ScriptedModbusServer((_, pdu) => + pdu[0] == 0x03 + ? new byte[] { 0x03, 0x04, 0x00, 0x2A } // byteCount 4, only 1 register present + : null); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 2, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public async Task TruncatedBitResponse_ThrowsModbusException() + { + // A coil-read response claiming a byte count (2) larger than the frame + // carries, so a naive read would index past the end of the buffer. + using var server = new ScriptedModbusServer((_, pdu) => + pdu[0] == 0x01 + ? new byte[] { 0x01, 0x02, 0x01 } // byteCount 2, only 1 data byte present + : null); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + Assert.ThrowsAsync(async () => + await client.ReadCoilsAsync(1, 0, 9, CancellationToken.None).ConfigureAwait(false)); + } + + [Test] + public async Task Timeout_FaultsConnection_ThenReconnectSucceeds() + { + using var server = new ScriptedModbusServer((connection, pdu) => + { + // First connection: never respond so the client times out. Second + // (reconnect) connection: answer the register read normally. + if (connection == 0) + { + return null; + } + return new byte[] { 0x03, 0x02, 0x12, 0x34 }; + }); + + using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(300)); + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + + // The silent server causes the request to time out. + Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + + // The connection is now faulted: a follow-up operation fails fast and + // deterministically (it does not hang or reuse the desynced stream) + // and reports that a reconnect is required. + ModbusException? fault = Assert.ThrowsAsync(async () => + await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false)); + Assert.That(fault!.Message, Does.Contain("reconnect").IgnoreCase); + + // A fresh connect re-establishes the socket and the read now succeeds. + await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false); + ushort[] registers = await client + .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false); + Assert.That(registers, Has.Length.EqualTo(1)); + Assert.That(registers[0], Is.EqualTo((ushort)0x1234)); + } + + /// + /// A minimal Modbus TCP listener whose per-request response is supplied by + /// a script. The script receives the zero-based accepted-connection index + /// and the request PDU and returns the response PDU, or null to hold + /// the connection open without responding (used to provoke a timeout). + /// + private sealed class ScriptedModbusServer : IDisposable + { + public ScriptedModbusServer(Func responder) + { + m_responder = responder; + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + int connection = m_connections++; + _ = Task.Run(() => ServeAsync(client, connection)); + } + } + + private async Task ServeAsync(TcpClient client, int connection) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + while (!m_cts.IsCancellationRequested) + { + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int length = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, length).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + byte[] pdu = new byte[rest.Length - 1]; + Array.Copy(rest, 1, pdu, 0, pdu.Length); + + byte[]? responsePdu = m_responder(connection, pdu); + if (responsePdu is null) + { + // Hold the connection open without answering so the + // client's request times out. + await Task.Delay(Timeout.Infinite, m_cts.Token).ConfigureAwait(false); + return; + } + + byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu); + await stream.WriteAsync(frame).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + // Server shutting down. + } + catch (System.IO.IOException) + { + // Client disconnected. + } + } + } + + private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) + { + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = txnHi; + frame[1] = txnLo; + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = (byte)(length >> 8); + frame[5] = (byte)(length & 0xFF); + frame[6] = unit; + Array.Copy(pdu, 0, frame, 7, pdu.Length); + return frame; + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private readonly Func m_responder; + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new CancellationTokenSource(); + private int m_connections; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/ModbusWotExecutorHardeningTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/ModbusWotExecutorHardeningTests.cs new file mode 100644 index 0000000000..f828cbf96a --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/ModbusWotExecutorHardeningTests.cs @@ -0,0 +1,181 @@ +/* ======================================================================== + * 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.Immutable; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Modbus; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Tests.Support; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// Executor-level hardening tests for the Modbus binding: a function-only form + /// maps end-to-end onto the exact function code, and the executor re-validates + /// the address / quantity range before the ushort casts so a hand-built, + /// out-of-range compiled form fails fast instead of silently truncating. + /// + [TestFixture] + public sealed class ModbusWotExecutorHardeningTests + { + private static WotProtocolBinderRegistry Registry() + => new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new ModbusBindingPlanner() }, + new IWotBindingExecutor[] { new ModbusWotBindingExecutor() }); + + [Test] + public async Task Modbus_FunctionOnlyForm_ReadsHoldingRegister_EndToEnd() + { + using var server = new TestModbusServer(); + server.HoldingRegisters[100] = 0x1234; + server.HoldingRegisters[101] = 0x5678; + + // Function-only form: modv:function 3 (read holding registers), no + // modv:entity. The planner must map it onto the holding-register space. + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"level\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + "/1\",\"modv:function\":3,\"modv:address\":100," + + "\"modv:quantity\":2,\"modv:type\":\"int32\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + Assert.That(read.Addressing.Metadata["entity"], Is.EqualTo("holdingRegister")); + Assert.That(read.OperationInfo.Method, Is.EqualTo("readHoldingRegisters")); + + IWotBindingChannel channel = await registry.OpenChannelAsync(read); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(0x12345678)); + } + } + + [Test] + public void Modbus_Executor_RevalidatesOutOfRangeAddress_BeforeCast() + { + // A hand-built compiled form whose address is beyond the 16-bit Modbus + // space would truncate to a valid ushort without the executor's + // re-validation. The executor must refuse it before opening a socket. + var addressing = new WotAddressingDescriptor( + "holdingRegister:70000:1@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "70000") + .Add("quantity", "1") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty.Add("type", "uint16")); + var form = new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "readHoldingRegisters"), + payload, + ImmutableArray.Empty, isExecutable: true); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false)); + } + + [Test] + public void Modbus_Executor_RevalidatesRangeOverflow_BeforeCast() + { + var addressing = new WotAddressingDescriptor( + "holdingRegister:65530:10@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "65530") + .Add("quantity", "10") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty.Add("type", "uint16")); + var form = new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "readHoldingRegisters"), + payload, + ImmutableArray.Empty, isExecutable: true); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false)); + } + + [Test] + public void Modbus_Executor_RejectsQuantityThatWouldTruncateToZero() + { + var addressing = new WotAddressingDescriptor( + "holdingRegister:0:65536@1", + ImmutableDictionary.Empty + .Add("entity", "holdingRegister") + .Add("address", "0") + .Add("quantity", "65536") + .Add("unitId", "1")); + var payload = new WotPayloadDescriptor( + "application/octet-stream", "octet-stream", + ImmutableDictionary.Empty.Add("type", "uint16")); + var form = new WotCompiledForm( + new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor( + "modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"), + addressing, + new WotOperationDescriptor( + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + "readHoldingRegisters"), + payload, + ImmutableArray.Empty, + isExecutable: true); + + var executor = new ModbusWotBindingExecutor(); + Assert.ThrowsAsync( + async () => await executor + .ActivateAsync(form, new WotExecutorContext()) + .ConfigureAwait(false)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/ModbusWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/ModbusWotExecutorTests.cs new file mode 100644 index 0000000000..ed5b9026e8 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/ModbusWotExecutorTests.cs @@ -0,0 +1,123 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Modbus; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Tests.Support; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// End-to-end tests for the Modbus TCP executor against an in-process simulator. + [TestFixture] + public sealed class ModbusWotExecutorTests + { + private static WotProtocolBinderRegistry Registry() + => new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new ModbusBindingPlanner() }, + new IWotBindingExecutor[] { new ModbusWotBindingExecutor() }); + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + => registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + + [Test] + public async Task Modbus_ReadWriteHoldingRegisterInt32_EndToEnd() + { + using var server = new TestModbusServer(); + // 0x12345678 stored big-endian across two holding registers. + server.HoldingRegisters[100] = 0x1234; + server.HoldingRegisters[101] = 0x5678; + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"level\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + "/1\",\"modv:entity\":\"holdingRegister\",\"modv:address\":100," + + "\"modv:quantity\":2,\"modv:type\":\"int32\"}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(0x12345678)); + } + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync(new DataValue(new Variant(1000042))); + Assert.That(result.Success, Is.True); + } + + Assert.That(server.HoldingRegisters[100], Is.EqualTo((ushort)(1000042 >> 16))); + Assert.That(server.HoldingRegisters[101], Is.EqualTo((ushort)(1000042 & 0xFFFF))); + } + + [Test] + public async Task Modbus_ReadWriteCoil_EndToEnd() + { + using var server = new TestModbusServer(); + server.Coils[10] = true; + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" + + server.Port + "/1\",\"modv:entity\":\"coil\",\"modv:address\":10,\"modv:quantity\":1}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm write = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync(); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(true)); + } + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync(new DataValue(new Variant(false))); + Assert.That(result.Success, Is.True); + } + + Assert.That(server.Coils[10], Is.False); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/MqttWotConnectionTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/MqttWotConnectionTests.cs new file mode 100644 index 0000000000..a5fb97ea8f --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/MqttWotConnectionTests.cs @@ -0,0 +1,171 @@ +/* ======================================================================== + * 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.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Mqtt; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// Unit tests for the MQTT transport-security policy: mqtts enables TLS + /// and defaults to port 8883, credentials / trust are applied through the + /// provider, the executor fails closed when a required credential is + /// unresolved, and username / password material never downgrades to a + /// plaintext connection. + /// + [TestFixture] + public sealed class MqttWotConnectionTests + { + private static WotCompiledForm Compiled(string href, bool withSecurity) + { + string security = withSecurity + ? "\"securityDefinitions\":{\"basic_sc\":{\"scheme\":\"basic\"}},\"security\":\"basic_sc\"," + : string.Empty; + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + security + + "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + href + + "\",\"op\":[\"writeproperty\"]}]}}}"; + var registry = new WotProtocolBinderRegistry(new IWotProtocolBinder[] { new MqttBindingPlanner() }); + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + } + + private static WotExecutorContext Context(IWotCredentialProvider credentials) + => new WotExecutorContext(credentials); + + private static Task PrepareAsync( + WotCompiledForm form, MqttWotBindingOptions options, IWotCredentialProvider credentials) + => MqttWotConnection.PrepareAsync(form, Context(credentials), options, "client-id", CancellationToken.None) + .AsTask(); + + [Test] + public async Task PlainMqtt_UsesPlaintext_DefaultPort1883() + { + WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: false); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance); + + Assert.That(plan.UseTls, Is.False); + Assert.That(plan.Port, Is.EqualTo(1883)); + Assert.That(plan.HasCredentials, Is.False); + } + + [Test] + public async Task Mqtts_EnablesTls_DefaultPort8883() + { + WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: false); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance); + + Assert.That(plan.UseTls, Is.True); + Assert.That(plan.Port, Is.EqualTo(8883)); + } + + [Test] + public async Task Mqtts_HonoursExplicitPort() + { + WotCompiledForm form = Compiled("mqtts://broker:9999/things/p", withSecurity: false); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance); + + Assert.That(plan.UseTls, Is.True); + Assert.That(plan.Port, Is.EqualTo(9999)); + } + + [Test] + public async Task Mqtts_WithResolvedCredentials_AppliesThem() + { + WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: true); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, new MqttWotBindingOptions(), new UserPasswordCredentialProvider()); + + Assert.That(plan.UseTls, Is.True); + Assert.That(plan.HasCredentials, Is.True); + } + + [Test] + public void Mqtts_RequiredCredentialUnresolved_FailsClosed() + { + WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: true); + + Assert.That(form.Security, Is.Not.Empty, "The form must declare a security scheme."); + Assert.ThrowsAsync( + async () => await PrepareAsync(form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance)); + } + + [Test] + public void PlainMqtt_WithCredentials_FailsClosed_NoPlaintextDowngrade() + { + WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: true); + + // The provider resolves username / password but the connection is plain + // mqtt://, so sending the credentials would leak them in clear text. + Assert.ThrowsAsync( + async () => await PrepareAsync( + form, new MqttWotBindingOptions(), new UserPasswordCredentialProvider())); + } + + [Test] + public async Task PlainMqtt_WithCredentials_AllowedWhenExplicitlyOptedIn() + { + WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: true); + + MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync( + form, + new MqttWotBindingOptions { AllowCredentialsOverPlaintext = true }, + new UserPasswordCredentialProvider()); + + Assert.That(plan.UseTls, Is.False); + Assert.That(plan.HasCredentials, Is.True); + } + + private sealed class UserPasswordCredentialProvider : IWotCredentialProvider + { + public ValueTask ResolveAsync( + WotCredentialReference reference, CancellationToken cancellationToken = default) + => new ValueTask(new WotCredential( + WotSecurityScheme.Basic, + properties: ImmutableDictionary.Empty + .Add("username", "device") + .Add("password", "secret"))); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/MqttWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/MqttWotExecutorTests.cs new file mode 100644 index 0000000000..8d94620540 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/MqttWotExecutorTests.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading.Tasks; +using MQTTnet.Server; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Mqtt; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// End-to-end tests for the MQTT executor against an ephemeral in-process broker. + [TestFixture] + public sealed class MqttWotExecutorTests + { + private static int FreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + int port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + return port; + } + + private static WotProtocolBinderRegistry Registry() + => new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new MqttBindingPlanner() }, + new IWotBindingExecutor[] { new MqttWotBindingExecutor( + new MqttWotBindingOptions { ReadTimeout = TimeSpan.FromSeconds(5) }) }); + + private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td) + => registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + + [Test] + public async Task Mqtt_PublishSubscribeObserve_EndToEnd() + { + int port = FreePort(); + MqttServerOptions serverOptions = new MqttServerOptionsBuilder() + .WithDefaultEndpoint() + .WithDefaultEndpointPort(port) + .WithDefaultEndpointBoundIPAddress(IPAddress.Loopback) + .Build(); + MqttServer broker = new MqttServerFactory().CreateMqttServer(serverOptions); + await broker.StartAsync(); + try + { + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{" + + "\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"mqtt://127.0.0.1:" + port + + "/things/temp\",\"mqv:qos\":1,\"mqv:retain\":true}]}," + + "\"watch\":{\"type\":\"number\",\"observable\":true,\"forms\":[{\"href\":\"mqtt://127.0.0.1:" + + port + "/things/temp\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}]}}}"; + + WotProtocolBinderRegistry registry = Registry(); + WotBindingPlan plan = Plan(registry, td); + WotCompiledForm write = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm read = plan.CompiledForms.First( + f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + WotCompiledForm observe = plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + // Publish a retained value, then read it back. + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write); + await using (writeChannel.ConfigureAwait(false)) + { + Assert.That((await writeChannel.WriteAsync(new DataValue(new Variant(42L)))).Success, Is.True); + } + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42L)); + } + + // Observe, then publish a new value and expect a notification. + var received = new ConcurrentQueue(); + IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe); + await using (observeChannel.ConfigureAwait(false)) + { + IWotSubscription subscription = await observeChannel.ObserveAsync(n => + { + if (n.Value.WrappedValue.AsBoxedObject() is long value) + { + received.Enqueue(value); + } + }); + await using (subscription.ConfigureAwait(false)) + { + await Task.Delay(200); + IWotBindingChannel publisher = await registry.OpenChannelAsync(write); + await using (publisher.ConfigureAwait(false)) + { + await publisher.WriteAsync(new DataValue(new Variant(77L))); + } + Assert.That(await WaitForAsync(received, 77), Is.True, + "The MQTT observe channel must deliver the published change."); + } + } + } + finally + { + await broker.StopAsync(); + broker.Dispose(); + } + } + + private static async Task WaitForAsync(ConcurrentQueue queue, long expected) + { + for (int i = 0; i < 60; i++) + { + if (queue.Contains(expected)) + { + return true; + } + await Task.Delay(50).ConfigureAwait(false); + } + return false; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/Opc.Ua.WotCon.Binding.Tests.csproj b/tests/Opc.Ua.WotCon.Binding.Tests/Opc.Ua.WotCon.Binding.Tests.csproj new file mode 100644 index 0000000000..493936d787 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/Opc.Ua.WotCon.Binding.Tests.csproj @@ -0,0 +1,45 @@ + + + Exe + net8.0;net9.0;net10.0 + $(CustomTestTarget) + Opc.Ua.WotCon.Binding.Tests + enable + false + false + $(NoWarn);CS1591;CA2007;CA2000;CA1014;CA1859;CA1861;NUnit2046;NUnit4002 + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/OpcUaWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/OpcUaWotExecutorTests.cs new file mode 100644 index 0000000000..0569e6e3b7 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/OpcUaWotExecutorTests.cs @@ -0,0 +1,351 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.Client.TestFramework; +using Opc.Ua.Server.TestFramework; +using Opc.Ua.Tests; +using Opc.Ua.WotCon.Binding.OpcUa; +using Opc.Ua.WotCon.Binding.Planners; +using Quickstarts.ReferenceServer; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// End-to-end tests proving OPC UA-to-OPC UA translation: a WoT Thing + /// Description describing an OPC UA target is compiled and executed + /// against a real in-process OPC UA server (a reference server). The + /// fixture starts the server and connects a single session once, and + /// each test exercises one operation: readproperty, writeproperty with + /// readback, observeproperty (native data-change subscription), + /// invokeaction (a real Method with ordered input/output arguments, + /// resolved through uav:componentOf) and subscribeevent (a real + /// EventNotifier and an emitted event, asserting selected fields). The + /// counter, action and event forms address their NodeIds with the + /// portable nsu= form to prove namespace-table resolution. + /// + [TestFixture] + public sealed class OpcUaWotExecutorTests + { + // Server_ServerStatus_CurrentTime (readable UtcTime) and + // Server_ServerStatus_State (a read-only Int32) are standard nodes + // every OPC UA server exposes; they need no namespace resolution. + private const string CurrentTimeNodeId = "i=2258"; + private const string StateNodeId = "i=2259"; + + // The ReferenceServer's own namespace; nodes below are addressed with + // the portable nsu= form so resolution goes through the session's + // namespace table rather than a guessed namespace index. + private const string ReferenceServerNamespace = "http://opcfoundation.org/Quickstarts/ReferenceServer"; + private const string CounterNodeId = "nsu=" + ReferenceServerNamespace + ";s=Scalar_Static_Int32"; + private const string AddMethodNodeId = "nsu=" + ReferenceServerNamespace + ";s=Methods_Add"; + private const string MethodsObjectNodeId = "nsu=" + ReferenceServerNamespace + ";s=Methods"; + private const string TriggerNode01Id = "nsu=" + ReferenceServerNamespace + ";s=NodeIds_Events_TriggerNode01"; + + // The standard Server object (i=2253, ns=0) addressed portably too + // (nsu= for the base namespace); every ReportEvent call in this + // stack reports starting at the Server object, so it always receives + // events regardless of where the event's SourceNode lives. + private const string ServerObjectNodeId = "nsu=http://opcfoundation.org/UA/;i=2253"; + + private ServerFixture m_serverFixture = null!; + private ISession m_session = null!; + private WotProtocolBinderRegistry m_registry = null!; + private WotBindingPlan m_plan = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + string pkiRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + m_serverFixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = "opc.tcp", + SecurityNone = true, + AutoAccept = true, + AllNodeManagers = true + }; + await m_serverFixture.StartAsync(pkiRoot).ConfigureAwait(false); + + var clientFixture = new ClientFixture(telemetry); + await clientFixture.LoadClientConfigurationAsync(pkiRoot).ConfigureAwait(false); + var url = new Uri("opc.tcp://localhost:" + m_serverFixture.Port.ToString(CultureInfo.InvariantCulture)); + m_session = await clientFixture.ConnectAsync(url, SecurityPolicies.None).ConfigureAwait(false); + + m_registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new OpcUaBindingPlanner() }, + new IWotBindingExecutor[] + { + new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => new ValueTask(m_session), + DisposeSession = false, + ObserveInterval = TimeSpan.FromMilliseconds(100) + }) + }); + + string ep = url.ToString(); + string td = BuildThingDescription(ep); + m_plan = m_registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, System.Text.Encoding.UTF8.GetBytes(td))); + + Assert.That(m_plan.Diagnostics.Any(d => d.IsError), Is.False, + "The Thing Description must compile without diagnostic errors: " + + string.Join("; ", m_plan.Diagnostics.Where(d => d.IsError).Select(d => d.Message))); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session is not null) + { + await m_session.CloseAsync().ConfigureAwait(false); + m_session.Dispose(); + } + if (m_serverFixture is not null) + { + await m_serverFixture.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task ReadProperty_ReturnsRealServerValueAsync() + { + WotCompiledForm read = m_plan.CompiledForms.First( + f => f.AffordanceName == "time" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, "Reading a real OPC UA node must succeed."); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.Not.Null); + } + } + + [Test] + public async Task WriteProperty_ReadOnlyNode_MapsBadStatusAsync() + { + WotCompiledForm write = m_plan.CompiledForms.First( + f => f.AffordanceName == "state" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(1))).ConfigureAwait(false); + Assert.That(result.Success, Is.False, + "Writing a read-only OPC UA node must be translated and its bad status mapped."); + } + } + + [Test] + public async Task WriteProperty_WithReadback_RoundTripsThroughPortableNodeIdAsync() + { + const int expected = 4242; + WotCompiledForm write = m_plan.CompiledForms.First( + f => f.AffordanceName == "counter" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm read = m_plan.CompiledForms.First( + f => f.AffordanceName == "counter" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel writeChannel = await m_registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult writeResult = await writeChannel + .WriteAsync(new DataValue(new Variant(expected))).ConfigureAwait(false); + Assert.That(writeResult.Success, Is.True, + $"Writing the counter property (portable nsu= NodeId) must succeed: {writeResult.Error}"); + } + + IWotBindingChannel readChannel = await m_registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult readResult = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(readResult.Success, Is.True, + $"Reading back the counter property must succeed: {readResult.Error}"); + Assert.That(readResult.Value.WrappedValue.TryGetValue(out int actual), Is.True); + Assert.That(actual, Is.EqualTo(expected), + "The read-back value must match the value written through the binding."); + } + } + + [Test] + public async Task ObserveProperty_DeliversNotificationViaNativeSubscriptionAsync() + { + WotCompiledForm observe = m_plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .ObserveAsync(n => received.Enqueue(n.Value.WrappedValue.AsBoxedObject())).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + bool got = false; + for (int i = 0; i < 100 && !got; i++) + { + got = !received.IsEmpty; + await Task.Delay(50).ConfigureAwait(false); + } + Assert.That(got, Is.True, + "The observe channel must deliver a value from the server via a native MonitoredItem."); + } + } + } + + [Test] + public async Task InvokeAction_RealMethod_ReturnsOrderedOutputArgumentsAsync() + { + WotCompiledForm invoke = m_plan.CompiledForms.First( + f => f.AffordanceName == "add" && f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel + .InvokeAsync(new Variant[] { new Variant(2.5f), new Variant(3u) }).ConfigureAwait(false); + Assert.That(result.Success, Is.True, + $"Invoking the real 'Methods_Add' method (resolved via uav:componentOf) must succeed: {result.Error}"); + Assert.That(result.Outputs.Count, Is.EqualTo(1)); + Assert.That(result.Outputs[0].WrappedValue.TryGetValue(out float sum), Is.True); + Assert.That(sum, Is.EqualTo(5.5f).Within(0.0001f), + "The Add method sums its Float and UInt32 arguments in order."); + } + } + + [Test] + public async Task SubscribeEvent_RealEventNotifier_DeliversSelectedFieldsAsync() + { + WotCompiledForm subscribe = m_plan.CompiledForms.First( + f => f.AffordanceName == "trigger" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(subscribe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .SubscribeEventAsync(n => received.Enqueue(n)).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + NodeId triggerNodeId = ResolvePortableNodeId(TriggerNode01Id); + var write = new WriteValue + { + NodeId = triggerNodeId, + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(42)) + }; + WriteResponse writeResponse = await m_session + .WriteAsync(null, new WriteValue[] { write }, CancellationToken.None).ConfigureAwait(false); + Assert.That(StatusCode.IsGood(writeResponse.Results[0]), Is.True, + "Writing the trigger node must succeed and fire a BaseEvent."); + + WotNotification? notification = null; + for (int i = 0; i < 100 && notification is null; i++) + { + if (!received.TryDequeue(out notification)) + { + await Task.Delay(50).ConfigureAwait(false); + } + } + Assert.That(notification, Is.Not.Null, + "The subscribeevent channel must deliver the event triggered by the write."); + + Assert.That(notification!.EventFields.TryGetValue("EventId", out DataValue eventIdValue), Is.True); + Assert.That(eventIdValue.WrappedValue.TryGetValue(out ByteString eventId), Is.True); + Assert.That(eventId.Length, Is.GreaterThan(0), "EventId must be a non-empty identifier."); + + Assert.That(notification.EventFields.TryGetValue("EventType", out DataValue eventTypeValue), Is.True); + Assert.That(eventTypeValue.WrappedValue.TryGetValue(out NodeId eventType), Is.True); + Assert.That(eventType, Is.EqualTo(Opc.Ua.Types.ObjectTypeIds.BaseEventType)); + + Assert.That(notification.EventFields.TryGetValue("SourceNode", out DataValue sourceNodeValue), Is.True); + Assert.That(sourceNodeValue.WrappedValue.TryGetValue(out NodeId sourceNode), Is.True); + Assert.That(sourceNode, Is.EqualTo(triggerNodeId), + "SourceNode must be the trigger variable that raised the event."); + + Assert.That(notification.EventFields.TryGetValue("Severity", out DataValue severityValue), Is.True); + Assert.That(severityValue.WrappedValue.TryGetValue(out ushort severity), Is.True); + Assert.That(severity, Is.EqualTo((ushort)EventSeverity.Medium)); + + Assert.That(notification.EventFields.TryGetValue("Message", out DataValue messageValue), Is.True); + Assert.That(messageValue.WrappedValue.TryGetValue(out LocalizedText message), Is.True); + Assert.That(message.Text, Does.Contain("Trigger event")); + + // The primary DataValue carries the same Message text with a + // Good status and the event's own Time / ReceiveTime. + Assert.That(StatusCode.IsGood(notification.Value.StatusCode), Is.True); + Assert.That(notification.Value.WrappedValue.TryGetValue(out LocalizedText primaryMessage), Is.True); + Assert.That(primaryMessage.Text, Does.Contain("Trigger event")); + } + } + } + + private static string BuildThingDescription(string endpoint) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"title\":\"t\",\"properties\":{" + + "\"time\":{\"type\":\"string\",\"forms\":[{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + + CurrentTimeNodeId + "\"}]}," + + "\"watch\":{\"type\":\"string\",\"observable\":true,\"forms\":[{\"href\":\"" + endpoint + + "\",\"uav:id\":\"" + CurrentTimeNodeId + "\",\"op\":[\"observeproperty\"]}]}," + + "\"state\":{\"type\":\"integer\",\"forms\":[{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + + StateNodeId + "\"}]}," + + "\"counter\":{\"type\":\"integer\",\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + CounterNodeId + "\",\"op\":[\"writeproperty\"]}," + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + CounterNodeId + "\",\"op\":[\"readproperty\"]}" + + "]}}," + + "\"actions\":{\"add\":{\"forms\":[{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + AddMethodNodeId + + "\",\"uav:componentOf\":\"" + MethodsObjectNodeId + "\",\"op\":[\"invokeaction\"]}]}}," + + "\"events\":{\"trigger\":{\"forms\":[{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + + ServerObjectNodeId + "\",\"op\":[\"subscribeevent\"]}]}}}"; + } + + /// + /// Resolves a portable nsu= NodeId string directly against the + /// connected session's namespace table (mirroring the fallback the + /// executor itself uses), so the test can address the trigger + /// variable without hard-coding a namespace index. + /// + private NodeId ResolvePortableNodeId(string value) + { + ExpandedNodeId expanded = ExpandedNodeId.Parse(value); + return ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/PollingWotSubscriptionTests.cs b/tests/Opc.Ua.WotCon.Binding.Tests/PollingWotSubscriptionTests.cs new file mode 100644 index 0000000000..c2654536bb --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/PollingWotSubscriptionTests.cs @@ -0,0 +1,141 @@ +/* ======================================================================== + * 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.Concurrent; +using System.Collections.Immutable; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; + +namespace Opc.Ua.WotCon.Binding.Tests +{ + /// + /// Unit tests for transient-fault + /// recovery and disposal semantics. + /// + [TestFixture] + public sealed class PollingWotSubscriptionTests + { + private static WotCompiledForm Form() + => new WotCompiledForm( + new WotBindingIdentity("test", "1.0", "urn:test"), + WotAffordanceKind.Property, "p", "/properties/p/forms/0", + WoTBindingCapabilityEnum.ObserveProperty, "observeproperty", + new WotEndpointDescriptor("test", "h", 1, "test://h"), + new WotAddressingDescriptor("t"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ObserveProperty, "observeproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + ImmutableArray.Empty, isExecutable: true); + + [Test] + public async Task TransientPollException_IsReportedAndPollingContinues() + { + int calls = 0; + var errors = new ConcurrentQueue(); + var recovered = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var subscription = new PollingWotSubscription( + Form(), + _ => + { + // Fault the first iteration, then succeed so recovery can be + // observed. A permanently faulting loop would never reach the + // second successful iteration. + if (Interlocked.Increment(ref calls) == 1) + { + throw new InvalidOperationException("transient poll fault"); + } + recovered.TrySetResult(true); + return default; + }, + TimeSpan.FromMilliseconds(20), + onError: errors.Enqueue); + + await using (subscription.ConfigureAwait(false)) + { + Task done = await Task.WhenAny(recovered.Task, Task.Delay(5000)).ConfigureAwait(false); + Assert.That(done, Is.SameAs(recovered.Task), + "The poll loop must keep polling after a transient fault."); + } + + Assert.That(errors.Count, Is.GreaterThanOrEqualTo(1), + "The transient fault must be reported to the error handler."); + Assert.That(Volatile.Read(ref calls), Is.GreaterThanOrEqualTo(2), + "Polling must continue after a transient fault."); + } + + [Test] + public async Task DisposeAsync_AfterCallbackFault_CompletesCleanly() + { + var faulted = new TaskCompletionSource( + TaskCreationOptions.RunContinuationsAsynchronously); + + var subscription = new PollingWotSubscription( + Form(), + _ => throw new InvalidOperationException("always faults"), + TimeSpan.FromMilliseconds(10), + onError: _ => faulted.TrySetResult(true)); + + // Ensure at least one faulting iteration has been observed. + Task observed = await Task.WhenAny(faulted.Task, Task.Delay(5000)).ConfigureAwait(false); + Assert.That(observed, Is.SameAs(faulted.Task), "The callback fault must be reported."); + + // DisposeAsync must not rethrow the callback fault and must complete + // promptly (the cancellation source is disposed in a finally). + Task dispose = subscription.DisposeAsync().AsTask(); + Task first = await Task.WhenAny(dispose, Task.Delay(5000)).ConfigureAwait(false); + Assert.That(first, Is.SameAs(dispose), "DisposeAsync must not hang after a callback fault."); + Assert.DoesNotThrowAsync(async () => await dispose.ConfigureAwait(false), + "DisposeAsync must never rethrow a transient poll/callback fault."); + } + + [Test] + public async Task DisposeAsync_DoesNotSwallowNorSurfaceCancellation() + { + using var started = new SemaphoreSlim(0, 1); + var subscription = new PollingWotSubscription( + Form(), + async token => + { + started.Release(); + await Task.Delay(Timeout.Infinite, token).ConfigureAwait(false); + }, + TimeSpan.FromMilliseconds(10)); + + Assert.That(await started.WaitAsync(5000).ConfigureAwait(false), Is.True); + + // Cooperative cancellation on dispose stops the loop cleanly without + // surfacing an OperationCanceledException from DisposeAsync. + Assert.DoesNotThrowAsync(async () => await subscription.DisposeAsync().ConfigureAwait(false)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/Support/TestHttpServer.cs b/tests/Opc.Ua.WotCon.Binding.Tests/Support/TestHttpServer.cs new file mode 100644 index 0000000000..b3e131f2ec --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/Support/TestHttpServer.cs @@ -0,0 +1,283 @@ +/* ======================================================================== + * 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.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding.Tests.Support +{ + /// A single response returned by the in-process test HTTP server. + public sealed class TestHttpResponse + { + public TestHttpResponse( + int status, string contentType, byte[] body, + IReadOnlyDictionary? headers = null) + { + Status = status; + ContentType = contentType; + Body = body; + Headers = headers; + } + + public int Status { get; } + + public string ContentType { get; } + + public byte[] Body { get; } + + /// Gets optional extra response headers (for example Location). + public IReadOnlyDictionary? Headers { get; } + + public static TestHttpResponse Json(int status, string json) + => new TestHttpResponse(status, "application/json", Encoding.UTF8.GetBytes(json)); + + /// Creates a redirect response (default 302) carrying a Location header. + public static TestHttpResponse Redirect(string location, int status = 302) + => new TestHttpResponse(status, "text/plain", Array.Empty(), + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Location"] = location }); + } + + /// A parsed request handed to the richer test-server handler. + public sealed class TestHttpRequest + { + public TestHttpRequest(string method, string path, byte[] body, IReadOnlyDictionary headers) + { + Method = method; + Path = path; + Body = body; + Headers = headers; + } + + public string Method { get; } + + public string Path { get; } + + public byte[] Body { get; } + + public IReadOnlyDictionary Headers { get; } + } + + /// + /// A minimal in-process HTTP/1.1 server built on + /// (avoiding HttpListener URL-ACL requirements). It routes each request + /// to a supplied handler and is used for the HTTP executor end-to-end tests. + /// + public sealed class TestHttpServer : IDisposable + { + public TestHttpServer(Func handler) + : this(request => handler(request.Method, request.Path, request.Body)) + { + } + + public TestHttpServer(Func handler) + { + m_handler = handler; + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + BaseUrl = $"http://127.0.0.1:{Port}"; + m_loop = Task.Run(AcceptLoopAsync); + } + + public string BaseUrl { get; } + + public int Port { get; } + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + _ = Task.Run(() => HandleAsync(client)); + } + } + + private async Task HandleAsync(TcpClient client) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + TestHttpRequest? request = await ReadRequestAsync(stream).ConfigureAwait(false); + if (request is null) + { + return; + } + TestHttpResponse response = m_handler(request); + await WriteResponseAsync(stream, response).ConfigureAwait(false); + } + catch (IOException) + { + // Client disconnected. + } + } + } + + private static async Task ReadRequestAsync(NetworkStream stream) + { + var header = new MemoryStream(); + byte[] one = new byte[1]; + int matched = 0; + byte[] terminator = Encoding.ASCII.GetBytes("\r\n\r\n"); + while (matched < terminator.Length) + { + int read = await stream.ReadAsync(one.AsMemory(0, 1)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + header.WriteByte(one[0]); + matched = one[0] == terminator[matched] ? matched + 1 : (one[0] == terminator[0] ? 1 : 0); + } + + string[] lines = Encoding.ASCII.GetString(header.ToArray()).Split("\r\n"); + string[] requestLine = lines[0].Split(' '); + string method = requestLine.Length > 0 ? requestLine[0] : "GET"; + string path = requestLine.Length > 1 ? requestLine[1] : "/"; + int contentLength = 0; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + string line = lines[i]; + int colon = line.IndexOf(':', StringComparison.Ordinal); + if (colon <= 0) + { + continue; + } + string name = line.Substring(0, colon).Trim(); + string value = line.Substring(colon + 1).Trim(); + headers[name] = value; + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) && + !int.TryParse(value, out contentLength)) + { + contentLength = 0; + } + } + + byte[] body = Array.Empty(); + if (contentLength > 0) + { + body = new byte[contentLength]; + int offset = 0; + while (offset < contentLength) + { + int read = await stream.ReadAsync(body.AsMemory(offset, contentLength - offset)).ConfigureAwait(false); + if (read == 0) + { + break; + } + offset += read; + } + } + return new TestHttpRequest(method, path, body, headers); + } + + private static async Task WriteResponseAsync(NetworkStream stream, TestHttpResponse response) + { + byte[] body = response.Body ?? Array.Empty(); + var builder = new StringBuilder(); + builder.Append("HTTP/1.1 ").Append(response.Status).Append(' ').Append(Reason(response.Status)).Append("\r\n"); + builder.Append("Content-Type: ").Append(response.ContentType).Append("\r\n"); + builder.Append("Content-Length: ").Append(body.Length).Append("\r\n"); + if (response.Headers is { Count: > 0 }) + { + foreach (KeyValuePair extra in response.Headers) + { + builder.Append(extra.Key).Append(": ").Append(extra.Value).Append("\r\n"); + } + } + builder.Append("Connection: close\r\n\r\n"); + byte[] head = Encoding.ASCII.GetBytes(builder.ToString()); + await stream.WriteAsync(head).ConfigureAwait(false); + if (body.Length > 0) + { + await stream.WriteAsync(body).ConfigureAwait(false); + } + await stream.FlushAsync().ConfigureAwait(false); + } + + private static string Reason(int status) + { + return status switch + { + 200 => "OK", + 204 => "No Content", + 301 => "Moved Permanently", + 302 => "Found", + 303 => "See Other", + 307 => "Temporary Redirect", + 308 => "Permanent Redirect", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Status" + }; + } + + private readonly Func m_handler; + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new CancellationTokenSource(); + } +} diff --git a/tests/Opc.Ua.WotCon.Binding.Tests/Support/TestModbusServer.cs b/tests/Opc.Ua.WotCon.Binding.Tests/Support/TestModbusServer.cs new file mode 100644 index 0000000000..b12b2c13ad --- /dev/null +++ b/tests/Opc.Ua.WotCon.Binding.Tests/Support/TestModbusServer.cs @@ -0,0 +1,239 @@ +/* ======================================================================== + * 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.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Binding.Tests.Support +{ + /// + /// A minimal in-process Modbus TCP server / simulator supporting the read and + /// write function codes required by the WoT Modbus binding (FC 1/3/4/5/6/16). + /// + public sealed class TestModbusServer : IDisposable + { + public TestModbusServer() + { + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + } + + public int Port { get; } + + public ushort[] HoldingRegisters { get; } = new ushort[1024]; + + public ushort[] InputRegisters { get; } = new ushort[1024]; + + public bool[] Coils { get; } = new bool[1024]; + + public void Dispose() + { + m_cts.Cancel(); + m_listener.Stop(); + m_listener.Dispose(); + try + { + m_loop.Wait(2000); + } + catch (AggregateException) + { + // Ignore teardown faults. + } + m_cts.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!m_cts.IsCancellationRequested) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + _ = Task.Run(() => ServeAsync(client)); + } + } + + private async Task ServeAsync(TcpClient client) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + while (!m_cts.IsCancellationRequested) + { + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int length = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, length).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + byte[] pdu = new byte[rest.Length - 1]; + Array.Copy(rest, 1, pdu, 0, pdu.Length); + byte[] responsePdu = Process(pdu); + byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu); + await stream.WriteAsync(frame).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + } + catch (System.IO.IOException) + { + // Client disconnected. + } + } + } + + private byte[] Process(byte[] pdu) + { + byte function = pdu[0]; + switch (function) + { + case 0x01: + return ReadBits(pdu, Coils, function); + case 0x03: + return ReadRegisters(pdu, HoldingRegisters, function); + case 0x04: + return ReadRegisters(pdu, InputRegisters, function); + case 0x05: + { + int address = (pdu[1] << 8) | pdu[2]; + Coils[address] = pdu[3] == 0xFF; + return new[] { function, pdu[1], pdu[2], pdu[3], pdu[4] }; + } + case 0x06: + { + int address = (pdu[1] << 8) | pdu[2]; + HoldingRegisters[address] = (ushort)((pdu[3] << 8) | pdu[4]); + return new[] { function, pdu[1], pdu[2], pdu[3], pdu[4] }; + } + case 0x10: + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + for (int i = 0; i < quantity; i++) + { + HoldingRegisters[address + i] = (ushort)((pdu[6 + (i * 2)] << 8) | pdu[7 + (i * 2)]); + } + return new[] { function, pdu[1], pdu[2], pdu[3], pdu[4] }; + } + default: + return new[] { (byte)(function | 0x80), (byte)0x01 }; + } + } + + private static byte[] ReadRegisters(byte[] pdu, ushort[] store, byte function) + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + byte[] response = new byte[2 + (quantity * 2)]; + response[0] = function; + response[1] = (byte)(quantity * 2); + for (int i = 0; i < quantity; i++) + { + response[2 + (i * 2)] = (byte)(store[address + i] >> 8); + response[3 + (i * 2)] = (byte)(store[address + i] & 0xFF); + } + return response; + } + + private static byte[] ReadBits(byte[] pdu, bool[] store, byte function) + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + int byteCount = (quantity + 7) / 8; + byte[] response = new byte[2 + byteCount]; + response[0] = function; + response[1] = (byte)byteCount; + for (int i = 0; i < quantity; i++) + { + if (store[address + i]) + { + response[2 + (i / 8)] |= (byte)(1 << (i % 8)); + } + } + return response; + } + + private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) + { + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = txnHi; + frame[1] = txnLo; + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = (byte)(length >> 8); + frame[5] = (byte)(length & 0xFF); + frame[6] = unit; + Array.Copy(pdu, 0, frame, 7, pdu.Length); + return frame; + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly CancellationTokenSource m_cts = new CancellationTokenSource(); + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Binding/WotBinderRegistryTests.cs b/tests/Opc.Ua.WotCon.Tests/Binding/WotBinderRegistryTests.cs new file mode 100644 index 0000000000..bb80647150 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Binding/WotBinderRegistryTests.cs @@ -0,0 +1,241 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Samples; + +namespace Opc.Ua.WotCon.Tests.Binding +{ + /// + /// Exercises the aggregating : + /// deterministic selection, version coexistence, executable upgrade when an + /// executor is present, capability exposure and unsupported classification. + /// + [TestFixture] + public sealed class WotBinderRegistryTests + { + private static readonly string[] s_stubVersions = ["1.0", "2.0"]; + private static readonly WoTBindingCapabilityEnum[] s_readCapabilities = + [WoTBindingCapabilityEnum.ReadProperty]; + private static readonly string[] s_jsonContentTypes = ["application/json"]; + + private static WotBindingPlanRequest Request(string affordance, string href, string extraTerms = "") + { + string terms = string.IsNullOrEmpty(extraTerms) ? string.Empty : "," + extraTerms; + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"" + affordance + "\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + + href + "\"" + terms + "}]}}}"; + return WotBindingPlanRequest.FromDocument("xid", WoTDocumentKindEnum.ThingDescription, + System.Text.Encoding.UTF8.GetBytes(td)); + } + + [Test] + public void Prepare_SelectsBinderByScheme() + { + var registry = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + + WotBindingPlan modbus = registry.Prepare(Request("m", "modbus+tcp://plc:502/1", + "\"modv:entity\":\"holdingRegister\",\"modv:address\":0,\"modv:quantity\":1")); + WotBindingPlan http = registry.Prepare(Request("h", "https://d/x")); + + Assert.That(modbus.CompiledForms.All(f => f.Binding.Id == "w3c.modbus"), Is.True); + Assert.That(http.CompiledForms.All(f => f.Binding.Id == "w3c.http"), Is.True); + } + + [Test] + public void Prepare_NoBinder_MarksFormUnsupported() + { + var registry = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + + WotBindingPlan plan = registry.Prepare(Request("x", "ftp://legacy/thing")); + + Assert.That(plan.FullySupported, Is.False); + Assert.That(plan.UnsupportedForms, Is.Not.Empty); + } + + [Test] + public void Prepare_PlannerOnly_ProducesNonExecutableForms() + { + var registry = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + + WotBindingPlan plan = registry.Prepare(Request("t", "coap://d/temp", "\"cov:method\":\"GET\"")); + + Assert.That(plan.FullySupported, Is.True); + Assert.That(plan.HasExecutableForms, Is.False); + Assert.That(plan.HasNonExecutableForms, Is.True); + } + + [Test] + public void Prepare_ModbusReadOnlyEntity_DefaultOps_KeepsReadPlan() + { + var registry = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + + // An input register carries the default read+write property ops. The + // write op is not executable against a read-only entity, but it must be + // dropped with a warning rather than an error: an error would set + // HasErrors and cause the whole form (read binding included) to be + // dropped as unsupported. + WotBindingPlan input = registry.Prepare(Request("sensor", "modbus+tcp://plc:502/1", + "\"modv:entity\":\"inputRegister\",\"modv:address\":0,\"modv:quantity\":1")); + WotBindingPlan discrete = registry.Prepare(Request("flag", "modbus+tcp://plc:502/1", + "\"modv:entity\":\"discreteInput\",\"modv:address\":0,\"modv:quantity\":1")); + + foreach (WotBindingPlan plan in new[] { input, discrete }) + { + Assert.That(plan.FullySupported, Is.True, "The read-only form must not be dropped as unsupported."); + Assert.That( + plan.CompiledForms.Any(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty), Is.True, + "The read binding must be preserved."); + Assert.That( + plan.CompiledForms.Any(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty), Is.False, + "The read-only write op must be dropped, not materialized."); + Assert.That( + plan.Diagnostics.Any(d => + d.Code == WotBindingDiagnosticCode.ConflictingFields && + d.Severity == Opc.Ua.Wot.WotDiagnosticSeverity.Warning), + Is.True, + "The dropped write must be reported as a warning, not an error."); + } + } + + [Test] + public void Prepare_WithExecutor_UpgradesToExecutable() + { + var store = new MemoryWotStore(); + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new MemoryWotBinder() }, + new IWotBindingExecutor[] { new MemoryWotBindingExecutor(store) }); + + WotBindingPlan plan = registry.Prepare(Request("t", "mem://store/key")); + + Assert.That(plan.FullySupported, Is.True); + Assert.That(plan.HasExecutableForms, Is.True); + Assert.That(plan.CompiledForms.Any(f => f.IsExecutable), Is.True); + } + + [Test] + public void Registry_ExposesOneCapabilityPerBinder() + { + var registry = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + + IReadOnlyList capabilities = registry.Capabilities; + + Assert.That(capabilities.Count, Is.EqualTo(8)); + Assert.That(capabilities.Select(c => c.BindingUri), + Has.Some.EqualTo(HttpBindingPlanner.BindingUri)); + Assert.That(capabilities.Select(c => c.BindingUri), + Has.Some.EqualTo(OpcUaBindingPlanner.BindingUri)); + } + + [Test] + public void Registry_MultipleVersionsCoexist() + { + var registry = new WotProtocolBinderRegistry(new IWotProtocolBinder[] + { + new StubBinder("1.0"), + new StubBinder("2.0") + }); + + Assert.That(registry.Binders.Count, Is.EqualTo(2)); + Assert.That( + registry.Binders.Select(b => b.Identity.Version), + Is.EquivalentTo(s_stubVersions)); + Assert.That(registry.Capabilities.Count, Is.EqualTo(2)); + } + + [Test] + public void Registry_ExplicitPin_OverridesSchemeSelection() + { + var registry = new WotProtocolBinderRegistry(new IWotProtocolBinder[] + { + new HttpBindingPlanner(), + new StubBinder("1.0") + }); + + // A stub-scheme href with an explicit pin on the stub binder id. + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"t\":{\"forms\":[{\"href\":\"stub://d/x\"}]}}}"; + var selection = new WotBindingSelectionContext( + System.Collections.Immutable.ImmutableArray.Create("stub.binder"), + System.Collections.Immutable.ImmutableArray.Empty); + var request = new WotBindingPlanRequest("xid", WoTDocumentKindEnum.ThingDescription, + WotFormExtractor.Extract(System.Text.Encoding.UTF8.GetBytes(td)), + selection: selection); + + WotBindingPlan plan = registry.Prepare(request); + + Assert.That(plan.CompiledForms.All(f => f.Binding.Id == "stub.binder"), Is.True); + } + + /// A minimal stub binder used for version and selection tests. + private sealed class StubBinder : WotProtocolBinderBase + { + private static readonly string[] s_schemes = { "stub" }; + + public StubBinder(string version) + { + Identity = new WotBindingIdentity("stub.binder", version, "urn:stub", "Stub"); + Capability = new WotBindingCapability("urn:stub", "Stub", + new WotBindingSource("urn:stub", version, WotBindingMaturity.UnofficialDraft), + s_readCapabilities, + s_jsonContentTypes, + isExecutable: false); + } + + public override WotBindingIdentity Identity { get; } + + public override WotBindingCapability Capability { get; } + + protected override IReadOnlyCollection Schemes => s_schemes; + + public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context) + => MatchStandard(form, context, null); + + public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context) + { + var entry = new WotCompiledForm( + Identity, form.Kind, form.AffordanceName, form.JsonPointer, + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("stub", "d", -1, "stub://d"), + new WotAddressingDescriptor(form.AffordanceName), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + System.Collections.Immutable.ImmutableArray.Empty, + isExecutable: false); + return WotBindingCompilation.Supported( + System.Collections.Immutable.ImmutableArray.Create(entry), + System.Collections.Immutable.ImmutableArray.Empty); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Binding/WotBindingTestSupport.cs b/tests/Opc.Ua.WotCon.Tests/Binding/WotBindingTestSupport.cs new file mode 100644 index 0000000000..76a5eb57b6 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Binding/WotBindingTestSupport.cs @@ -0,0 +1,78 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Text.Json; +using Opc.Ua.WotCon.Binding; + +namespace Opc.Ua.WotCon.Tests.Binding +{ + /// Shared helpers for the protocol-binding planner and registry tests. + internal static class WotBindingTestSupport + { + /// Extracts a single, non-empty form for an affordance from a document. + public static WotAffordanceForm Form(string json, string affordance) + { + ImmutableArray forms = WotFormExtractor.Extract(Encoding.UTF8.GetBytes(json)); + return forms.First(f => f.AffordanceName == affordance && + f.FormElement.ValueKind == JsonValueKind.Object); + } + + /// Extracts all forms from a document. + public static ImmutableArray Forms(string json) + => WotFormExtractor.Extract(Encoding.UTF8.GetBytes(json)); + + /// Creates a default plan context. + public static WotBindingPlanContext Context() => new WotBindingPlanContext(); + + /// Wraps a property affordance with a single form in a Thing Description. + public static string Property(string name, string formJson) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"title\":\"t\",\"properties\":{\"" + name + "\":{\"type\":\"number\"," + + "\"forms\":[" + formJson + "]}}}"; + } + + /// Wraps an action affordance with a single form in a Thing Description. + public static string Action(string name, string formJson) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"title\":\"t\",\"actions\":{\"" + name + "\":{\"forms\":[" + formJson + "]}}}"; + } + + /// Wraps an event affordance with a single form in a Thing Description. + public static string Event(string name, string formJson) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\"," + + "\"title\":\"t\",\"events\":{\"" + name + "\":{\"forms\":[" + formJson + "]}}}"; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Binding/WotCodecTests.cs b/tests/Opc.Ua.WotCon.Tests/Binding/WotCodecTests.cs new file mode 100644 index 0000000000..e3deae4c68 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Binding/WotCodecTests.cs @@ -0,0 +1,98 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.WotCon.Binding; + +namespace Opc.Ua.WotCon.Tests.Binding +{ + /// Round-trip and selection tests for the built-in payload codecs. + [TestFixture] + public sealed class WotCodecTests + { + private static readonly WotPayloadDescriptor s_json = new WotPayloadDescriptor("application/json", "json"); + private static readonly WotPayloadDescriptor s_text = new WotPayloadDescriptor("text/plain", "text"); + private static readonly WotPayloadDescriptor s_octet = + new WotPayloadDescriptor("application/octet-stream", "octet-stream"); + + [Test] + public void Json_RoundTripsScalars() + { + AssertRoundTrip(JsonWotPayloadCodec.Instance, s_json, new Variant(42L), 42L); + AssertRoundTrip(JsonWotPayloadCodec.Instance, s_json, new Variant(true), true); + AssertRoundTrip(JsonWotPayloadCodec.Instance, s_json, new Variant("hello"), "hello"); + AssertRoundTrip(JsonWotPayloadCodec.Instance, s_json, new Variant(3.5), 3.5); + } + + [Test] + public void Text_RoundTripsString() + { + WotEncodeResult encoded = TextWotPayloadCodec.Instance.Encode(new Variant("abc"), s_text); + Assert.That(encoded.Success, Is.True); + WotDecodeResult decoded = TextWotPayloadCodec.Instance.Decode(encoded.Data, s_text); + Assert.That(decoded.Value.AsBoxedObject(), Is.EqualTo("abc")); + } + + [Test] + public void OctetStream_RoundTripsBytes() + { + byte[] payload = { 1, 2, 3, 4 }; + WotEncodeResult encoded = OctetStreamWotPayloadCodec.Instance.Encode( + new Variant(new ByteString(payload)), s_octet); + Assert.That(encoded.Success, Is.True); + Assert.That(encoded.Data.ToArray(), Is.EqualTo(payload)); + WotDecodeResult decoded = OctetStreamWotPayloadCodec.Instance.Decode(encoded.Data, s_octet); + WotEncodeResult reencoded = OctetStreamWotPayloadCodec.Instance.Encode(decoded.Value, s_octet); + Assert.That(reencoded.Data.ToArray(), Is.EqualTo(payload)); + } + + [Test] + public void Registry_SelectsByContentType() + { + var registry = WotPayloadCodecRegistry.Default; + + Assert.That(registry.TrySelect("application/json", out IWotPayloadCodec json), Is.True); + Assert.That(json.Id, Is.EqualTo("json")); + Assert.That(registry.TrySelect("text/plain", out IWotPayloadCodec text), Is.True); + Assert.That(text.Id, Is.EqualTo("text")); + Assert.That(registry.TrySelect("application/octet-stream", out IWotPayloadCodec octet), Is.True); + Assert.That(octet.Id, Is.EqualTo("octet-stream")); + } + + private static void AssertRoundTrip( + IWotPayloadCodec codec, WotPayloadDescriptor payload, Variant value, object expected) + { + WotEncodeResult encoded = codec.Encode(value, payload); + Assert.That(encoded.Success, Is.True); + WotDecodeResult decoded = codec.Decode(encoded.Data, payload); + Assert.That(decoded.Success, Is.True); + Assert.That(decoded.Value.AsBoxedObject(), Is.EqualTo(expected)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Binding/WotCustomBinderSampleTests.cs b/tests/Opc.Ua.WotCon.Tests/Binding/WotCustomBinderSampleTests.cs new file mode 100644 index 0000000000..56a3021979 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Binding/WotCustomBinderSampleTests.cs @@ -0,0 +1,82 @@ +/* ======================================================================== + * 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.Linq; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Samples; + +namespace Opc.Ua.WotCon.Tests.Binding +{ + /// + /// Verifies the worked sample custom binder end-to-end: it validates and + /// compiles a mem:// form and its executor performs read / write against + /// the in-process store, demonstrating the third-party code-behind pattern. + /// + [TestFixture] + public sealed class WotCustomBinderSampleTests + { + [Test] + public async Task SampleBinder_CompilesAndExecutesReadWrite() + { + var store = new MemoryWotStore(); + var registry = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new MemoryWotBinder() }, + new IWotBindingExecutor[] { new MemoryWotBindingExecutor(store) }); + + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," + + "\"properties\":{\"setpoint\":{\"type\":\"number\",\"forms\":[{\"href\":\"mem://store/setpoint\"}]}}}"; + WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td))); + + Assert.That(plan.FullySupported, Is.True); + Assert.That(plan.HasExecutableForms, Is.True); + + WotCompiledForm write = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult result = await writeChannel.WriteAsync(new DataValue(new Variant(42.5))); + Assert.That(result.Success, Is.True); + } + + IWotBindingChannel readChannel = await registry.OpenChannelAsync(read); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult result = await readChannel.ReadAsync(); + Assert.That(result.Success, Is.True); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42.5)); + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Binding/WotPlannerTests.cs b/tests/Opc.Ua.WotCon.Tests/Binding/WotPlannerTests.cs new file mode 100644 index 0000000000..02c11abc2b --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Binding/WotPlannerTests.cs @@ -0,0 +1,585 @@ +/* ======================================================================== + * 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.Linq; +using NUnit.Framework; +using Opc.Ua.Wot; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Planners; + +namespace Opc.Ua.WotCon.Tests.Binding +{ + /// + /// Exercises the eight shipped planner / validator binders (HTTP, CoAP, MQTT, + /// Modbus TCP, BACnet, PROFINET, LoRaWAN and OPC UA) across positive, + /// negative and bounds cases, verifying href / vocabulary validation, op + /// compatibility, required fields, immutable metadata and JSON-Pointer + /// diagnostics. + /// + [TestFixture] + public sealed class WotPlannerTests + { + // ---- HTTP ------------------------------------------------------------- + + [Test] + public void Http_ValidProperty_CompilesReadAndWrite() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", + "{\"href\":\"https://d.example.com/temp\",\"contentType\":\"application/json\"}"), + "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.HasErrors, Is.False); + Assert.That(result.Entries.Select(e => e.Operation), + Is.EquivalentTo(new[] { WoTBindingCapabilityEnum.ReadProperty, WoTBindingCapabilityEnum.WriteProperty })); + WotCompiledForm read = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.ReadProperty); + Assert.That(read.Endpoint.Scheme, Is.EqualTo("https")); + Assert.That(read.OperationInfo.Method, Is.EqualTo("GET")); + Assert.That(result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.WriteProperty) + .OperationInfo.Method, Is.EqualTo("PUT")); + } + + [Test] + public void Http_MethodOverride_IsHonoured() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Action("run", "{\"href\":\"http://d/run\",\"htv:methodName\":\"POST\"}"), + "run"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries[0].OperationInfo.Method, Is.EqualTo("POST")); + } + + [Test] + public void Http_InvalidMethod_IsRejectedWithPointer() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", "{\"href\":\"http://d/x\",\"htv:methodName\":\"FETCHY\"}"), + "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + WotBindingDiagnostic error = result.Diagnostics.First(d => d.IsError); + Assert.That(error.Code, Is.EqualTo(WotBindingDiagnosticCode.InvalidFieldValue)); + Assert.That(error.JsonPointer, Does.Contain("htv:methodName")); + } + + [Test] + public void Http_MissingScheme_IsRejected() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", "{\"href\":\"relative/path\"}"), "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.InvalidHref), Is.True); + } + + // ---- MQTT ------------------------------------------------------------- + + [Test] + public void Mqtt_ValidProperty_ResolvesTopicAndQos() + { + var planner = new MqttBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", + "{\"href\":\"mqtt://broker:1883/things/temp\",\"mqv:qos\":1,\"mqv:retain\":true}"), + "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm write = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.WriteProperty); + Assert.That(write.Addressing.Target, Is.EqualTo("things/temp")); + Assert.That(write.Addressing.Metadata["qos"], Is.EqualTo("1")); + Assert.That(write.Addressing.Metadata["retain"], Is.EqualTo("true")); + Assert.That(write.OperationInfo.Method, Is.EqualTo("publish")); + } + + [Test] + public void Mqtt_InvalidQos_IsRejected() + { + var planner = new MqttBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", "{\"href\":\"mqtt://b:1883/t\",\"mqv:qos\":5}"), "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.InvalidFieldValue && + d.Term == "mqv:qos"), Is.True); + } + + // ---- Modbus ----------------------------------------------------------- + + [Test] + public void Modbus_HoldingRegisterInt32_Compiles() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("level", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:entity\":\"holdingRegister\"," + + "\"modv:address\":100,\"modv:quantity\":2,\"modv:type\":\"int32\"}"), + "level"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm read = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.ReadProperty); + Assert.That(read.Addressing.Metadata["entity"], Is.EqualTo("holdingRegister")); + Assert.That(read.Addressing.Metadata["address"], Is.EqualTo("100")); + Assert.That(read.Addressing.Metadata["quantity"], Is.EqualTo("2")); + Assert.That(read.Addressing.Metadata["unitId"], Is.EqualTo("1")); + Assert.That(read.Payload.Metadata["type"], Is.EqualTo("int32")); + Assert.That(read.OperationInfo.Method, Is.EqualTo("readHoldingRegisters")); + } + + [Test] + public void Modbus_QuantityBeyondBounds_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("bulk", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:entity\":\"holdingRegister\"," + + "\"modv:address\":0,\"modv:quantity\":200}"), + "bulk"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.BoundsExceeded), Is.True); + } + + [Test] + public void Modbus_WriteToReadOnlyEntity_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("sensor", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:entity\":\"inputRegister\"," + + "\"modv:address\":0,\"modv:quantity\":1}"), + "sensor"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + // The read entry compiles; the write entry is rejected as read-only. + Assert.That(result.Entries.Any(e => e.Operation == WoTBindingCapabilityEnum.ReadProperty), Is.True); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.ConflictingFields), Is.True); + } + + [Test] + public void Modbus_AddressBeyond16Bit_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("far", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:entity\":\"holdingRegister\"," + + "\"modv:address\":70000,\"modv:quantity\":1}"), + "far"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Term == "modv:address"), Is.True); + } + + [Test] + public void Modbus_AddressPlusQuantityOverflow_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("edge", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:entity\":\"holdingRegister\"," + + "\"modv:address\":65530,\"modv:quantity\":10}"), + "edge"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.BoundsExceeded), Is.True); + } + + [Test] + public void Modbus_FunctionOnlyNumericCode_MapsEntityAndMethod() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("reg", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:function\":3," + + "\"modv:address\":10,\"modv:quantity\":2,\"modv:type\":\"int32\"}"), + "reg"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm read = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.ReadProperty); + Assert.That(read.Addressing.Metadata["entity"], Is.EqualTo("holdingRegister")); + Assert.That(read.Addressing.Metadata["functionCode"], Is.EqualTo("3")); + Assert.That(read.OperationInfo.Method, Is.EqualTo("readHoldingRegisters")); + // A read function drops the default write op with a diagnostic. + Assert.That(result.Entries.Any(e => e.Operation == WoTBindingCapabilityEnum.WriteProperty), Is.False); + } + + [Test] + public void Modbus_FunctionOnlyMnemonic_MapsCoilWrite() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("relay", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:function\":\"writeSingleCoil\"," + + "\"modv:address\":5}"), + "relay"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm write = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.WriteProperty); + Assert.That(write.Addressing.Metadata["entity"], Is.EqualTo("coil")); + Assert.That(write.OperationInfo.Method, Is.EqualTo("writeSingleCoil")); + // A write function drops the default read op with a diagnostic. + Assert.That(result.Entries.Any(e => e.Operation == WoTBindingCapabilityEnum.ReadProperty), Is.False); + } + + [Test] + public void Modbus_EntityFunctionMismatch_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("bad", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:entity\":\"coil\"," + + "\"modv:function\":\"readHoldingRegisters\",\"modv:address\":0}"), + "bad"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.IsError && + d.Code == WotBindingDiagnosticCode.ConflictingFields), Is.True); + } + + [Test] + public void Modbus_InvalidFunction_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("weird", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:function\":99,\"modv:address\":0}"), + "weird"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.InvalidFieldValue && + d.Term == "modv:function"), Is.True); + } + + [Test] + public void Modbus_ExplicitWriteOpWithReadFunction_IsRejected() + { + var planner = new ModbusBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("ro", + "{\"href\":\"modbus+tcp://plc:502/1\",\"modv:function\":\"readCoil\"," + + "\"modv:address\":0,\"op\":[\"writeproperty\"]}"), + "ro"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + // The only op is a write against a read function; every entry is dropped. + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.ConflictingFields), Is.True); + } + + [Test] + public void Mqtts_Scheme_CompilesWithSecureEndpoint() + { + var planner = new MqttBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", + "{\"href\":\"mqtts://broker:8883/things/temp\",\"mqv:qos\":1}"), + "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm write = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.WriteProperty); + Assert.That(write.Endpoint.Scheme, Is.EqualTo("mqtts")); + Assert.That(write.Endpoint.Port, Is.EqualTo(8883)); + } + + // ---- CoAP (planner only, non-executable) ------------------------------ + + [Test] + public void Coap_ValidForm_CompilesButNonExecutable() + { + var planner = new CoapBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", "{\"href\":\"coap://d/temp\",\"cov:method\":\"GET\"}"), "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries.All(e => e.IsExecutable), Is.False, + "The CoAP planner declares its forms non-executable (Capability.IsExecutable == false)."); + } + + [Test] + public void Coap_InvalidMethod_IsRejected() + { + var planner = new CoapBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", "{\"href\":\"coap://d/x\",\"cov:method\":\"NOPE\"}"), "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Term == "cov:method"), Is.True); + } + + // ---- BACnet (schema only) -------------------------------------------- + + [Test] + public void Bacnet_ValidObject_Compiles() + { + var planner = new BacnetBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", + "{\"bacv:objectType\":\"analogInput\",\"bacv:instanceNumber\":1," + + "\"bacv:propertyIdentifier\":\"presentValue\"}"), + "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries[0].Addressing.Target, Is.EqualTo("analogInput:1:presentValue")); + Assert.That(result.Entries.All(e => e.IsExecutable), Is.False); + } + + [Test] + public void Bacnet_MissingInstance_IsRejected() + { + var planner = new BacnetBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", + "{\"bacv:objectType\":\"analogInput\",\"bacv:propertyIdentifier\":\"presentValue\"}"), + "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.MissingRequiredField && + d.Term == "bacv:instanceNumber"), Is.True); + } + + // ---- PROFINET (schema only) ------------------------------------------ + + [Test] + public void Profinet_ValidSlot_Compiles() + { + var planner = new ProfinetBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", "{\"pnv:slot\":1,\"pnv:subslot\":2,\"pnv:index\":100}"), "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries.All(e => e.IsExecutable), Is.False); + } + + [Test] + public void Profinet_MissingIndex_IsRejected() + { + var planner = new ProfinetBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", "{\"pnv:slot\":1,\"pnv:subslot\":2}"), "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Term == "pnv:index"), Is.True); + } + + // ---- LoRaWAN (schema only) ------------------------------------------- + + [Test] + public void LoRaWan_ValidDevice_Compiles() + { + var planner = new LoRaWanBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", + "{\"lorawan:DevEUI\":\"0011223344556677\",\"lorawan:fPort\":10}"), "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + Assert.That(result.Entries[0].Addressing.Metadata["devEui"], Is.EqualTo("0011223344556677")); + Assert.That(result.Entries.All(e => e.IsExecutable), Is.False); + } + + [Test] + public void LoRaWan_InvalidDevEui_IsRejected() + { + var planner = new LoRaWanBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", "{\"lorawan:DevEUI\":\"not-hex\"}"), "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.InvalidFieldValue), Is.True); + } + + // ---- OPC UA ----------------------------------------------------------- + + [Test] + public void OpcUa_ValidNodeId_Compiles() + { + var planner = new OpcUaBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", + "{\"href\":\"opc.tcp://server:4840\",\"uav:id\":\"ns=2;i=5\"}"), "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm read = result.Entries.First(e => e.Operation == WoTBindingCapabilityEnum.ReadProperty); + Assert.That(read.Addressing.Target, Is.EqualTo("ns=2;i=5")); + Assert.That(read.Endpoint.Scheme, Is.EqualTo("opc.tcp")); + Assert.That(read.OperationInfo.Method, Is.EqualTo("Read")); + } + + [Test] + public void OpcUa_MissingNodeId_IsRejected() + { + var planner = new OpcUaBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("t", "{\"href\":\"opc.tcp://server:4840\"}"), "t"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.False); + Assert.That(result.Diagnostics.Any(d => d.Code == WotBindingDiagnosticCode.MissingRequiredField), Is.True); + } + + [Test] + public void OpcUa_ActionCarriesComponentOf() + { + var planner = new OpcUaBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Action("run", + "{\"href\":\"opc.tcp://server:4840\",\"uav:id\":\"ns=2;i=9\",\"uav:componentOf\":\"ns=2;i=1\"}"), + "run"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm invoke = result.Entries[0]; + Assert.That(invoke.Operation, Is.EqualTo(WoTBindingCapabilityEnum.InvokeAction)); + Assert.That(invoke.Addressing.Metadata["componentOf"], Is.EqualTo("ns=2;i=1")); + Assert.That(invoke.OperationInfo.Method, Is.EqualTo("Call")); + } + + [Test] + public void OpcUa_EventCarriesAuthoredEventFields() + { + var planner = new OpcUaBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Event("alarmActive", + "{\"href\":\"opc.tcp://server:4840\",\"uav:id\":\"ns=2;i=42\"," + + "\"uav:eventFields\":[\"ActiveState/Id\",\"Severity\"],\"op\":[\"subscribeevent\"]}"), + "alarmActive"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm subscribe = result.Entries.First( + e => e.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + Assert.That(subscribe.Addressing.Metadata["eventFields"], Is.EqualTo("ActiveState/Id|Severity")); + } + + [Test] + public void OpcUa_EventWithoutEventFields_OmitsMetadataKey() + { + var planner = new OpcUaBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Event("trigger", + "{\"href\":\"opc.tcp://server:4840\",\"uav:id\":\"ns=2;i=42\",\"op\":[\"subscribeevent\"]}"), + "trigger"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + + Assert.That(result.IsSupported, Is.True); + WotCompiledForm subscribe = result.Entries.First( + e => e.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + Assert.That(subscribe.Addressing.Metadata.ContainsKey("eventFields"), Is.False); + } + + // ---- Capability metadata --------------------------------------------- + + [Test] + public void Capabilities_PinSourcesAndNeverClaimRegistryCurrent() + { + foreach (IWotProtocolBinder binder in WotBuiltInBinders.CreateAll()) + { + Assert.That(binder.Capability.Source.SpecificationUri, Is.Not.Empty, + $"{binder.Identity.Id} must pin a source URL."); + Assert.That(binder.Capability.Source.Maturity, Is.Not.EqualTo(WotBindingMaturity.RegistryCurrent), + $"{binder.Identity.Id} must not claim W3C Registry Current status."); + WoTBindingCapabilityDataType dataType = binder.Capability.ToDataType(); + Assert.That(dataType.BindingUri, Is.Not.Empty); + Assert.That(dataType.DraftMaturity, Is.Not.Empty); + } + } + + [Test] + public void Diagnostics_ExposeJsonPointerViaSharedModel() + { + var planner = new HttpBindingPlanner(); + WotAffordanceForm form = WotBindingTestSupport.Form( + WotBindingTestSupport.Property("temp", "{\"contentType\":\"application/json\"}"), "temp"); + + WotBindingCompilation result = planner.Compile(form, WotBindingTestSupport.Context()); + WotBindingDiagnostic diagnostic = result.Diagnostics.First(d => d.IsError); + WotDiagnostic shared = diagnostic.ToWotDiagnostic(); + + Assert.That(shared.Location?.JsonPointer, Does.StartWith("/properties/temp/forms/0")); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/CombinedModelPreservationTests.cs b/tests/Opc.Ua.WotCon.Tests/CombinedModelPreservationTests.cs new file mode 100644 index 0000000000..9c6be69263 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/CombinedModelPreservationTests.cs @@ -0,0 +1,157 @@ +/* ======================================================================== + * 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.Reflection; +using NUnit.Framework; +using Opc.Ua.WotCon.Client; + +namespace Opc.Ua.WotCon.Tests +{ + /// + /// Guards the alignment of the proof to the revised spec model: the + /// Opc.Ua.WotCon generated model is now produced once from the + /// combined Opc.Ua.WoTCon NodeSet2 (incorporating the OPC 10100-1 + /// v1.02 surface plus the additive registry nodes in one namespace) instead + /// of the standalone 1.02 ModelDesign and a separate Opc.Ua.WotCon.V2 + /// model. These tests prove that switching the generation source preserved + /// the exact 1.02 NodeIds, the typed method state/result surface and the + /// generated client API, and that the registry types now coexist in the + /// same namespace. + /// + [TestFixture] + [Category("WotCon")] + public class CombinedModelPreservationTests + { + [Test] + public void Incorporated102NodeIdsArePreservedExactly() + { + Assert.Multiple(() => + { + // Well-known ObjectType NodeIds (OPC 10100-1 v1.02). + Assert.That(ObjectTypes.WoTAssetConnectionManagementType, Is.EqualTo(1u)); + Assert.That(ObjectTypes.IWoTAssetType, Is.EqualTo(42u)); + Assert.That(ObjectTypes.WoTAssetFileType, Is.EqualTo(110u)); + + // The well-known WoTAssetConnectionManagement Object. + Assert.That(Objects.WoTAssetConnectionManagement, Is.EqualTo(31u)); + + // The deprecated method-type declarations keep their NodeIds. + Assert.That(Methods.CreateAssetMethodType, Is.EqualTo(90u)); + }); + } + + [Test] + public void Incorporated102BrowseNamesAndMethodsArePreserved() + { + Assert.Multiple(() => + { + Assert.That(BrowseNames.WoTAssetConnectionManagement, + Is.EqualTo("WoTAssetConnectionManagement")); + Assert.That(BrowseNames.CreateAsset, Is.EqualTo("CreateAsset")); + Assert.That(BrowseNames.DeleteAsset, Is.EqualTo("DeleteAsset")); + Assert.That(BrowseNames.DiscoverAssets, Is.EqualTo("DiscoverAssets")); + Assert.That(BrowseNames.ConnectionTest, Is.EqualTo("ConnectionTest")); + Assert.That(BrowseNames.HasWoTComponent, Is.EqualTo("HasWoTComponent")); + }); + } + + [Test] + public void TypedMethodResultsRetainTheirArguments() + { + // Generating the 1.02 methods from the NodeSet2 must still emit the + // typed method state result structures with their arguments (these + // were lost until the NodeSet2 argument-value decoder was fixed). + var create = new CreateAssetMethodStateResult(); + create.AssetId = new NodeId(1); + Assert.That(create.AssetId, Is.EqualTo(new NodeId(1))); + + var conn = new ConnectionTestMethodStateResult + { + Success = true, + Status = "ok" + }; + Assert.That(conn.Success, Is.True); + Assert.That(conn.Status, Is.EqualTo("ok")); + + var discover = new DiscoverAssetsMethodStateResult(); + _ = discover.AssetEndpoints; + Assert.That( + typeof(DiscoverAssetsMethodStateResult).GetProperty( + nameof(DiscoverAssetsMethodStateResult.AssetEndpoints)), + Is.Not.Null); + } + + [Test] + public void GeneratedClientApiExposesTypedAssetMethods() + { + // WoTAssetConnectionManagementTypeClient is the generated client + // proxy the WotConnectivityClient facade delegates to. + MethodInfo? createAsset = typeof(WoTAssetConnectionManagementTypeClient) + .GetMethod("CreateAssetAsync"); + Assert.That(createAsset, Is.Not.Null, + "generated client proxy must expose CreateAssetAsync"); + + Assert.That( + typeof(WoTAssetConnectionManagementTypeClient).GetMethod("ConnectionTestAsync"), + Is.Not.Null); + + // Public client facade surface is preserved. + Assert.That(typeof(WotConnectivityClient).GetMethod("CreateAssetAsync"), + Is.Not.Null); + Assert.That(typeof(WotConnectivityClient).GetMethod("DiscoverAssetsAsync"), + Is.Not.Null); + } + + [Test] + public void AdditiveRegistryTypesCoexistInTheWotConNamespace() + { + Assert.Multiple(() => + { + // Additive registry types now live in the same generated + // Opc.Ua.WotCon namespace (previously Opc.Ua.WotCon.V2), at + // their provisional 64000+ NodeIds. + Assert.That(ObjectTypes.WoTRegistryType, Is.EqualTo(64000u)); + Assert.That(ObjectTypes.ThingDescriptionGroupType, Is.EqualTo(64001u)); + Assert.That(Objects.WoTRegistry, Is.EqualTo(64100u)); + + // Registry DataTypes and enums are generated in Opc.Ua.WotCon. + Assert.That(WoTDocumentKindEnum.ThingDescription, + Is.Not.EqualTo(WoTDocumentKindEnum.ThingModel)); + Assert.That(new WoTValidationOutcomeDataType(), Is.Not.Null); + }); + } + + [Test] + public void ModelDeclaresOneWotConNamespace() + { + Assert.That(Namespaces.WotCon, + Is.EqualTo("http://opcfoundation.org/UA/WoT-Con/")); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs new file mode 100644 index 0000000000..4729ccca66 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotBindingCoordinatorTests.cs @@ -0,0 +1,437 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Binding; +using Opc.Ua.WotCon.Binding.Planners; +using Opc.Ua.WotCon.Binding.Samples; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the materialization coordinator's binding lifecycle: strict vs + /// degraded closure selection, non-executable degradation, and the + /// activate-after-commit / deactivate-before-retire ordering. + /// + [TestFixture] + public sealed class WotBindingCoordinatorTests + { + private static byte[] Td(string id, string href, string extraTerms = "") + { + string terms = string.IsNullOrEmpty(extraTerms) ? string.Empty : "," + extraTerms; + string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"id\":\"" + id + "\",\"title\":\"t\"," + + "\"properties\":{\"value\":{\"type\":\"number\",\"forms\":[{\"href\":\"" + href + "\"" + + terms + "}]}}}"; + return Encoding.UTF8.GetBytes(td); + } + + private static WotRegistryService Registry() => new WotRegistryService(); + + private static Task Upsert(WotRegistryService registry, string resourceId, byte[] content) + => registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + + [Test] + public async Task Strict_UnsupportedForm_FailsClosure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()) + { + StrictBindings = true + }; + await Upsert(registry, "td-a", Td("urn:td-a", "ftp://legacy/x")); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(host.AddCount, Is.EqualTo(0), "A strict closure with unsupported forms must not project."); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public async Task Degraded_UnsupportedForm_MaterializesWithWarningAndBindingFailure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()) + { + StrictBindings = false + }; + var events = new List(); + coordinator.Event += (_, e) => events.Add(e); + await Upsert(registry, "td-a", Td("urn:td-a", "ftp://legacy/x")); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(host.AddCount, Is.EqualTo(1), "A degraded closure still materializes nodes."); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(events.Any(e => e.Kind == WotMaterializationEventKind.BindingFailure), Is.True, + "Degraded mode must emit a binding failure event."); + } + + [Test] + public async Task NonExecutableForm_DegradesClosure() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry(WotBuiltInBinders.CreateAll()); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "coap://d/temp", "\"cov:method\":\"GET\"")); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(host.AddCount, Is.EqualTo(1)); + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Warning), + "A validated but non-executable binding degrades the closure."); + } + + [Test] + public async Task ExecutableForm_IsNotDegraded() + { + WotRegistryService registry = Registry(); + var host = new FakeWotProjectionHost(); + var binders = new WotProtocolBinderRegistry( + new IWotProtocolBinder[] { new MemoryWotBinder() }, + new IWotBindingExecutor[] { new MemoryWotBindingExecutor(new MemoryWotStore()) }); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")); + + WotRefreshResult result = await coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Success), + "A fully executable binding is not degraded."); + } + + [Test] + public async Task Lifecycle_ActivatesAfterCommit_DeactivatesBeforeRetire() + { + WotRegistryService registry = Registry(); + var timeline = new List(); + var host = new RecordingProjectionHost(timeline); + var binders = new RecordingBinderRegistry(timeline); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/value")); + + await coordinator.RefreshAsync(new WotRefreshRequest()); + await registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a"); + await coordinator.RefreshAsync(new WotRefreshRequest()); + + int add = timeline.IndexOf("add"); + int activate = timeline.IndexOf("activate"); + int deactivate = timeline.IndexOf("deactivate"); + int remove = timeline.IndexOf("remove"); + + Assert.That(add, Is.GreaterThanOrEqualTo(0)); + Assert.That(activate, Is.GreaterThan(add), "Activate must follow the projection commit."); + Assert.That(deactivate, Is.GreaterThanOrEqualTo(0)); + Assert.That(remove, Is.GreaterThan(deactivate), "Deactivate must precede retirement."); + } + + [Test] + public async Task Update_DeactivatesExactlyOldPlans_InCorrectOrder() + { + WotRegistryService registry = Registry(); + var recorder = new PlanRecorder(); + var host = new PlanRecordingHost(recorder); + var binders = new PlanRecordingBinderRegistry(recorder); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v1")); + await coordinator.RefreshAsync(new WotRefreshRequest()); + + // A content change triggers a shadow reload (an update, not a first add). + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v2")); + await coordinator.RefreshAsync(new WotRefreshRequest()); + + WotBindingPlan planV1 = binders.ActivatedPlans[0]; + WotBindingPlan planV2 = binders.ActivatedPlans[1]; + Assert.That(planV2, Is.Not.SameAs(planV1), "The update must prepare a new plan."); + + // Exactly one deactivation, and it is the old plan (never the new one). + Assert.That(binders.DeactivatedPlans, Has.Count.EqualTo(1)); + Assert.That(binders.DeactivatedPlans[0], Is.SameAs(planV1), + "Only the previously tracked plan may be deactivated on update."); + + // Order: the shadow switch happens first, then the old plan is + // deactivated, then the new plan is activated. + int shadow = recorder.IndexOf("shadow"); + int deactivateOld = recorder.IndexOf("deactivate", planV1); + int activateNew = recorder.IndexOf("activate", planV2); + Assert.That(shadow, Is.GreaterThanOrEqualTo(0), "The update must shadow-reload the projection."); + Assert.That(deactivateOld, Is.GreaterThan(shadow), + "The old plan must be deactivated only after the shadow switch succeeds."); + Assert.That(activateNew, Is.GreaterThan(deactivateOld), + "The new plan must be activated after the old plan is deactivated."); + } + + [Test] + public async Task Update_ShadowReloadFails_OldPlansRemainActive() + { + WotRegistryService registry = Registry(); + var recorder = new PlanRecorder(); + var host = new PlanRecordingHost(recorder); + var binders = new PlanRecordingBinderRegistry(recorder); + using var coordinator = new WotMaterializationCoordinator( + registry, host, binders, documentConverter: new FakeWotDocumentConverter()); + + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v1")); + await coordinator.RefreshAsync(new WotRefreshRequest()); + WotBindingPlan planV1 = binders.ActivatedPlans[0]; + + // The shadow switch fails: the old plans must remain active (no + // deactivation) and no new plan may be activated (rollback ordering). + host.FailShadowReload = true; + await Upsert(registry, "td-a", Td("urn:td-a", "mem://store/v2")); + await coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(binders.DeactivatedPlans, Is.Empty, + "A failed shadow switch must not deactivate the still-active old plan."); + Assert.That(binders.ActivatedPlans, Has.Count.EqualTo(1), + "A failed shadow switch must not activate the new plan."); + Assert.That(binders.ActivatedPlans[0], Is.SameAs(planV1)); + } + + private sealed class PlanRecorder + { + public List<(string Action, WotBindingPlan? Plan)> Events { get; } = new(); + + public void Record(string action, WotBindingPlan? plan = null) + { + lock (Events) + { + Events.Add((action, plan)); + } + } + + public int IndexOf(string action, WotBindingPlan? plan = null) + { + lock (Events) + { + return Events.FindIndex(e => + e.Action == action && (plan is null || ReferenceEquals(e.Plan, plan))); + } + } + } + + private sealed class PlanRecordingHost : IWotProjectionHost + { + public PlanRecordingHost(PlanRecorder recorder) => m_recorder = recorder; + + public bool FailShadowReload { get; set; } + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_recorder.Record("add"); + return new ValueTask(Handle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, CancellationToken cancellationToken = default) + { + if (FailShadowReload) + { + throw new System.IO.IOException("Injected shadow reload failure."); + } + m_recorder.Record("shadow"); + return new ValueTask(Handle(document)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_recorder.Record("immediate"); + return new ValueTask(Handle(document)); + } + + public ValueTask RemoveAsync(WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + m_recorder.Record("remove"); + return default; + } + + private static WotProjectionHandle Handle(WotProjectionDocument document) + => new WotProjectionHandle(document.ClosureKey, 1, new object(), ImmutableArray.Empty, 0); + + private readonly PlanRecorder m_recorder; + } + + private sealed class PlanRecordingBinderRegistry : IWotBinderRegistry + { + public PlanRecordingBinderRegistry(PlanRecorder recorder) => m_recorder = recorder; + + public List ActivatedPlans { get; } = new(); + public List DeactivatedPlans { get; } = new(); + + public IReadOnlyList Capabilities { get; } + = System.Array.Empty(); + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + ImmutableArray.Empty, isExecutable: true); + // A fresh plan instance per Prepare so old and new plans are + // distinguishable by reference identity. + return new WotBindingPlan(request.ResourceXid, + ImmutableArray.Empty, + ImmutableArray.Create(entry), + ImmutableArray.Empty, + ImmutableArray.Empty); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + ActivatedPlans.Add(plan); + m_recorder.Record("activate", plan); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + DeactivatedPlans.Add(plan); + m_recorder.Record("deactivate", plan); + return default; + } + + private readonly PlanRecorder m_recorder; + } + + private sealed class RecordingProjectionHost : IWotProjectionHost + { + public RecordingProjectionHost(List timeline) => m_timeline = timeline; + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_timeline.Add("add"); + return new ValueTask(Handle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, CancellationToken cancellationToken = default) + { + m_timeline.Add("shadow"); + return new ValueTask(Handle(document)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + m_timeline.Add("immediate"); + return new ValueTask(Handle(document)); + } + + public ValueTask RemoveAsync(WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + m_timeline.Add("remove"); + return default; + } + + private static WotProjectionHandle Handle(WotProjectionDocument document) + => new WotProjectionHandle(document.ClosureKey, 1, new object(), ImmutableArray.Empty, 0); + + private readonly List m_timeline; + } + + private sealed class RecordingBinderRegistry : IWotBinderRegistry + { + public RecordingBinderRegistry(List timeline) => m_timeline = timeline; + + public IReadOnlyList Capabilities { get; } + = System.Array.Empty(); + + public WotBindingPlan Prepare(WotBindingPlanRequest request) + { + var entry = new WotCompiledForm( + new WotBindingIdentity("rec", "1.0", "urn:rec"), + WotAffordanceKind.Property, "value", "/properties/value/forms/0", + WoTBindingCapabilityEnum.ReadProperty, "readproperty", + new WotEndpointDescriptor("rec", null, -1, "rec://x"), + new WotAddressingDescriptor("value"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"), + new WotPayloadDescriptor("application/json", "json"), + ImmutableArray.Empty, isExecutable: true); + return new WotBindingPlan(request.ResourceXid, + ImmutableArray.Empty, + ImmutableArray.Create(entry), + ImmutableArray.Empty, + ImmutableArray.Empty); + } + + public ValueTask ActivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + m_timeline.Add("activate"); + return default; + } + + public ValueTask DeactivateAsync(WotBindingPlan plan, CancellationToken cancellationToken = default) + { + m_timeline.Add("deactivate"); + return default; + } + + private readonly List m_timeline; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs new file mode 100644 index 0000000000..fabcdeeecb --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotDependencyGraphTests.cs @@ -0,0 +1,147 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the TD/TM dependency graph: reference extraction, closure + /// partitioning (weakly-connected components), topological ordering, and + /// missing-dependency and cycle detection. + /// + [TestFixture] + public sealed class WotDependencyGraphTests + { + private static readonly string[] s_tmTdResourceIds = ["tm", "td"]; + + private async Task Snapshot( + params (WoTDocumentKindEnum Kind, string Id, byte[] Content)[] docs) + { + using var service = new WotRegistryService(); + foreach ((WoTDocumentKindEnum kind, string id, byte[] content) in docs) + { + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = kind == WoTDocumentKindEnum.ThingModel + ? WotRegistryGroups.ThingModels + : WotRegistryGroups.ThingDescriptions, + ResourceId = id, + Kind = kind, + Content = content + }); + } + return service.Current; + } + + [Test] + public void ExtractReferences_FindsTmExtendsLinks() + { + byte[] doc = TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm-1"); + + IReadOnlyList<(string Href, string RefType)> references = + WotDependencyGraph.ExtractReferences(doc, 64); + + Assert.That(references.Any(r => r.Href == "urn:tm-1" && r.RefType == "tm:extends"), + Is.True); + } + + [Test] + public async Task BuildClosures_SharedModel_YieldsSingleClosure_TmFirst() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingModel, "tm", TestMaterialization.Tm("urn:tm")), + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:tm"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, snapshot.AllResources().ToList(), 64); + + Assert.That(closures.Length, Is.EqualTo(1)); + Assert.That(closures[0].IsProjectable, Is.True); + Assert.That( + closures[0].OrderedResources.Select(r => r.ResourceId), + Is.EqualTo(s_tmTdResourceIds)); + } + + [Test] + public async Task BuildClosures_IndependentResources_YieldSeparateClosures() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingDescription, "a", TestMaterialization.Td("urn:a")), + (WoTDocumentKindEnum.ThingDescription, "b", TestMaterialization.Td("urn:b"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, snapshot.AllResources().ToList(), 64); + + Assert.That(closures.Length, Is.EqualTo(2)); + Assert.That(closures.All(c => c.OrderedResources.Length == 1), Is.True); + } + + [Test] + public async Task BuildClosures_MissingDependency_IsFlagged() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingDescription, "td", + TestMaterialization.Td("urn:td", extendsHrefs: "urn:missing"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, snapshot.AllResources().ToList(), 64); + + Assert.That(closures.Length, Is.EqualTo(1)); + Assert.That(closures[0].HasMissingDependency, Is.True); + Assert.That(closures[0].IsProjectable, Is.False); + } + + [Test] + public async Task BuildClosures_Cycle_IsDetected() + { + WotRegistrySnapshot snapshot = await Snapshot( + (WoTDocumentKindEnum.ThingModel, "a", + TestMaterialization.Tm("urn:a", extendsHrefs: "urn:b")), + (WoTDocumentKindEnum.ThingModel, "b", + TestMaterialization.Tm("urn:b", extendsHrefs: "urn:a"))); + + ImmutableArray closures = + WotDependencyGraph.BuildClosures(snapshot, snapshot.AllResources().ToList(), 64); + + Assert.That(closures.Length, Is.EqualTo(1)); + Assert.That(closures[0].HasCycle, Is.True); + Assert.That(closures[0].IsProjectable, Is.False); + Assert.That(closures[0].Members.Length, Is.EqualTo(2), + "A cyclic closure must still report its members for diagnostics."); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs new file mode 100644 index 0000000000..7733273143 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationCoordinatorTests.cs @@ -0,0 +1,395 @@ +/* ======================================================================== + * 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.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Exercises the materialization coordinator against a recording projection + /// host and a deterministic converter, covering the dependency-closure, + /// unchanged-refresh, invalid-retention, shadow-reload and retirement + /// behaviours required by the WoT Connectivity V2 runtime. + /// + [TestFixture] + public sealed class WotMaterializationCoordinatorTests + { + private static readonly string[] s_tmTdSourceNames = ["tm-a", "td-a"]; + + private WotRegistryService m_registry = null!; + private FakeWotProjectionHost m_host = null!; + private FakeWotDocumentConverter m_converter = null!; + private WotMaterializationCoordinator m_coordinator = null!; + + [SetUp] + public void SetUp() + { + m_registry = new WotRegistryService(); + m_host = new FakeWotProjectionHost(); + m_converter = new FakeWotDocumentConverter(); + m_coordinator = new WotMaterializationCoordinator( + m_registry, m_host, documentConverter: m_converter); + } + + [TearDown] + public void TearDown() + { + m_coordinator.Dispose(); + m_registry.Dispose(); + } + + private Task RegisterTd(string resourceId, byte[] content) + => m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content + }).AsTask(); + + private Task RegisterTm(string resourceId, byte[] content) + => m_registry.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingModels, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingModel, + Content = content + }).AsTask(); + + [Test] + public async Task TmBeforeTd_CreatesSingleClosure_TmOrderedFirst() + { + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a")); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:tm-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "A shared closure must project as one runtime NodeManager."); + HostOperation op = m_host.Operations.Single(o => o.Op == "add"); + Assert.That(op.SourceNames, Is.EqualTo(s_tmTdSourceNames), + "Thing Models must be ordered before the Thing Descriptions that extend them."); + // With the default (no-op) binder, affordance forms have no binder and + // materialize as degraded nodes, so the projected outcome is Warning; + // both members nonetheless reach the Active load state. + Assert.That( + result.Results.Count(r => + r.Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning), + Is.EqualTo(2)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingModels, "tm-a")!.LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + } + + [Test] + public async Task TdBeforeTm_FailsThenSucceedsAfterTmRegistration() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", extendsHrefs: "urn:tm-a")); + + WotRefreshResult first = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(0), + "A Thing Description with a missing model dependency must not project."); + WoTResourceLoadResultDataType tdResult = + first.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(tdResult.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(tdResult.Phase, Is.EqualTo(WoTPhaseEnum.DependencyResolution)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Failed)); + + await RegisterTm("tm-a", TestMaterialization.Tm("urn:tm-a")); + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Registering the missing model must let the closure project."); + Assert.That( + second.Results.Count(r => + r.Outcome is WoTOutcomeEnum.Success or WoTOutcomeEnum.Warning), + Is.EqualTo(2)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + } + + [Test] + public async Task ExternalWebDependencyIsNotResolvedOutsideRegistry() + { + await RegisterTd( + "td-a", + TestMaterialization.Td( + "urn:td-a", + extendsHrefs: "https://example.invalid/models/pump.tm.jsonld")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.Zero); + WoTResourceLoadResultDataType tdResult = + result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(tdResult.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(tdResult.Phase, Is.EqualTo(WoTPhaseEnum.DependencyResolution)); + } + + [Test] + public async Task UnchangedRefresh_PreservesRegistration_NoModelEvent() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + WotRefreshResult second = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), "No new add on an unchanged refresh."); + Assert.That(m_host.ShadowCount, Is.EqualTo(0), "No shadow reload on an unchanged refresh."); + Assert.That(m_host.ImmediateCount, Is.EqualTo(0), + "No immediate reload on an unchanged refresh."); + Assert.That( + second.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task InvalidVersion_Failure_RetainsPreviousActiveProjection() + { + var events = new List(); + m_coordinator.Event += (_, e) => events.Add(e); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + WotResource afterFirst = + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!; + string activeBefore = afterFirst.ActiveVersionId!; + Assert.That(afterFirst.LoadState, Is.EqualTo(WoTLoadStateEnum.Active)); + + // A new version whose conversion fails. + m_converter.MarkInvalid("td-a"); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.EqualTo(0), + "A failed refresh must retain the previous active projection."); + WotResource afterFail = + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(afterFail.LoadState, Is.EqualTo(WoTLoadStateEnum.Failed)); + Assert.That(afterFail.ActiveVersionId, Is.EqualTo(activeBefore), + "The previously active version must be retained on failure."); + Assert.That( + result.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That( + events.Any(e => e.Kind == WotMaterializationEventKind.ValidationFailure), + Is.True, "A validation failure event must be emitted."); + } + + [Test] + public async Task VersionSwitch_UsesShadowReload() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), "A version switch must not re-add."); + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "A version switch must shadow-reload the projection."); + } + + [Test] + public async Task VersionSwitch_UsesImmediateReloadWhenConfigured() + { + m_coordinator.RetirementPolicy = WotProjectionRetirementPolicy.Immediate; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.ShadowCount, Is.Zero); + Assert.That(m_host.ImmediateCount, Is.EqualTo(1), + "Immediate retirement must use the host's immediate reload path."); + } + + [Test] + public async Task VersionSwitchCleanupWarningTracksCommittedReplacement() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v1")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + m_host.NextReloadWarning = "Prior-generation cleanup is pending."; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a", "v2")); + WotRefreshResult switched = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType result = + switched.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That(result.Message, Does.Contain("cleanup is pending")); + + WotRefreshResult unchanged = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.ShadowCount, Is.EqualTo(1), + "The committed replacement handle must remain tracked after a cleanup warning."); + Assert.That( + unchanged.Results.Single(r => r.ResourceId == "td-a").Outcome, + Is.EqualTo(WoTOutcomeEnum.Unchanged)); + } + + [Test] + public async Task Delete_RetiresProjection() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + Assert.That(m_host.AddCount, Is.EqualTo(1)); + + await m_registry.DeleteResourceAsync(WotRegistryGroups.ThingDescriptions, "td-a"); + await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.RemoveCount, Is.EqualTo(1), + "A deleted resource's projection must be retired."); + } + + [Test] + public async Task IndependentClosures_PartialSuccess() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + await RegisterTd("td-b", TestMaterialization.Td("urn:td-b")); + m_converter.MarkInvalid("td-b"); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(1), + "Only the projectable closure commits."); + Assert.That(result.Summary.Succeeded, Is.EqualTo(1u)); + Assert.That(result.Summary.Failed, Is.EqualTo(1u)); + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-a")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Active)); + Assert.That( + m_registry.Current.FindResource(WotRegistryGroups.ThingDescriptions, "td-b")! + .LoadState, + Is.EqualTo(WoTLoadStateEnum.Failed)); + } + + [Test] + public async Task Refresh_ExpectedGenerationMismatch_IsRejected() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + ExpectedGeneration = 99999 + }); + + Assert.That(result.Summary.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(m_host.AddCount, Is.EqualTo(0)); + } + + [Test] + public async Task DryRun_DoesNotCommit() + { + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + Options = new WoTRefreshOptionsDataType { DryRun = true } + }); + + Assert.That(m_host.AddCount, Is.EqualTo(0), "A dry run must not project."); + Assert.That(result.NewGeneration, Is.EqualTo(0u)); + } + + [Test] + public async Task DetailedResults_CarryNodeCountAndDigest() + { + m_converter.SetNodeCount("td-a", 7); + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest + { + RequestId = "req-1" + }); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.MaterializedNodeCount, Is.EqualTo(7u)); + Assert.That(td.ContentDigest.Length, Is.GreaterThan(0)); + Assert.That(result.Summary.RequestId, Is.EqualTo("req-1")); + } + + [Test] + public async Task RootNodeId_IsRecordedFromGeneratedNodeSet() + { + // The fake converter emits a NodeSet whose model namespace is + // urn:wot:{group}/{resource}; register it so the coordinator can + // resolve the recorded projection root into a server NodeId. + var namespaces = new NamespaceTable(); + string modelUri = $"urn:wot:{WotRegistryGroups.ThingDescriptions}/td-a"; + namespaces.Append(modelUri); + m_coordinator.ServerNamespaceUris = namespaces; + await RegisterTd("td-a", TestMaterialization.Td("urn:td-a")); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + WoTResourceLoadResultDataType td = result.Results.Single(r => r.ResourceId == "td-a"); + Assert.That(td.RootNodeId.IsNull, Is.False, + "A document with a root must report a non-null RootNodeId."); + Assert.That(td.RootNodeId.NamespaceIndex, + Is.EqualTo((ushort)namespaces.GetIndex(modelUri))); + WotResource resource = m_registry.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "td-a")!; + Assert.That(resource.RootNodeId, Is.Not.Null); + } + + [Test] + public async Task PlaceholderResource_WithoutVersion_IsNotProjected() + { + await m_registry.TryCreateResourceAsync( + WotRegistryGroups.ThingDescriptions, "empty", + WoTDocumentKindEnum.ThingDescription); + + WotRefreshResult result = await m_coordinator.RefreshAsync(new WotRefreshRequest()); + + Assert.That(m_host.AddCount, Is.EqualTo(0), + "A content-less placeholder resource must not project."); + Assert.That(result.Results, Is.Empty); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs new file mode 100644 index 0000000000..133dcd759a --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotMaterializationTestSupport.cs @@ -0,0 +1,197 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using System.Globalization; +using System.IO; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Opc.Ua.Export; +using Opc.Ua.WotCon.Server.Materialization; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// A test that records the add / shadow / + /// remove operations without a running server. + /// + internal sealed class FakeWotProjectionHost : IWotProjectionHost + { + public List Operations { get; } = new(); + + public int AddCount { get; private set; } + public int ShadowCount { get; private set; } + public int ImmediateCount { get; private set; } + public int RemoveCount { get; private set; } + public string NextReloadWarning { get; set; } = string.Empty; + + public ValueTask AddAsync( + WotProjectionDocument document, CancellationToken cancellationToken = default) + { + AddCount++; + Operations.Add(new HostOperation("add", document)); + return new ValueTask(MakeHandle(document)); + } + + public ValueTask ShadowReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + ShadowCount++; + Operations.Add(new HostOperation("shadow", document)); + long gen = (current?.Generation ?? 0) + 1; + string warning = NextReloadWarning; + NextReloadWarning = string.Empty; + return new ValueTask(MakeHandle(document, gen, warning)); + } + + public ValueTask ImmediateReloadAsync( + WotProjectionHandle current, WotProjectionDocument document, + CancellationToken cancellationToken = default) + { + ImmediateCount++; + Operations.Add(new HostOperation("immediate", document)); + long gen = (current?.Generation ?? 0) + 1; + string warning = NextReloadWarning; + NextReloadWarning = string.Empty; + return new ValueTask(MakeHandle(document, gen, warning)); + } + + public ValueTask RemoveAsync( + WotProjectionHandle handle, CancellationToken cancellationToken = default) + { + RemoveCount++; + Operations.Add(new HostOperation("remove", null, handle?.ClosureKey ?? string.Empty)); + return default; + } + + private static WotProjectionHandle MakeHandle( + WotProjectionDocument document, + long gen = 1, + string warning = "") + { + return new WotProjectionHandle( + document.ClosureKey, + gen, + new object(), + ImmutableArray.Empty, + 0, + warning); + } + } + + internal sealed class HostOperation + { + public HostOperation(string op, WotProjectionDocument? document, string closureKey = "") + { + Op = op; + Document = document; + ClosureKey = document?.ClosureKey ?? closureKey; + } + + public string Op { get; } + public WotProjectionDocument? Document { get; } + public string ClosureKey { get; } + + public IReadOnlyList SourceNames + { + get + { + var names = new List(); + if (Document is not null) + { + foreach (WotProjectionSource source in Document.Sources) + { + names.Add(source.Name); + } + } + return names; + } + } + } + + /// + /// A deterministic that returns a canned + /// NodeSet2 per resource id, or a failure for ids marked invalid. + /// + internal sealed class FakeWotDocumentConverter : IWotDocumentConverter + { + private readonly Dictionary m_nodeCounts = new(StringComparer.Ordinal); + private readonly HashSet m_invalid = new(StringComparer.Ordinal); + + public void SetNodeCount(string resourceId, int nodeCount) + => m_nodeCounts[resourceId] = nodeCount; + + public void MarkInvalid(string resourceId) => m_invalid.Add(resourceId); + + public void ClearInvalid(string resourceId) => m_invalid.Remove(resourceId); + + public WotConversionOutput Convert( + WotResource resource, ReadOnlyMemory content, WotRegistrySnapshot snapshot) + { + if (m_invalid.Contains(resource.ResourceId)) + { + return WotConversionOutput.Failure( + $"Injected conversion failure for '{resource.ResourceId}'."); + } + int nodeCount = m_nodeCounts.TryGetValue(resource.ResourceId, out int c) ? c : 2; + UANodeSet nodeSet = TestNodeSets.Make( + $"urn:wot:{resource.GroupId}/{resource.ResourceId}", nodeCount); + return WotConversionOutput.Success(nodeSet); + } + } + + internal static class TestNodeSets + { + public static UANodeSet Make(string modelUri, int nodeCount) + { + var builder = new StringBuilder(); + builder.Append(""); + builder.Append(""); + builder.Append("").Append(modelUri).Append(""); + builder.Append(""); + for (int i = 0; i < nodeCount; i++) + { + int id = 5000 + i; + builder.Append("Node") + .Append(i).Append(""); + } + builder.Append(""); + using var stream = new MemoryStream(Encoding.UTF8.GetBytes(builder.ToString())); + return UANodeSet.Read(stream)!; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs new file mode 100644 index 0000000000..04483f8a60 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Materialization/WotRefreshArgumentsTests.cs @@ -0,0 +1,209 @@ +/* ======================================================================== + * 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 NUnit.Framework; +using Opc.Ua.WotCon.Server.Materialization; + +namespace Opc.Ua.WotCon.Tests.Materialization +{ + /// + /// Unit tests for , the decoder for the + /// generated WoTRegistryType.Refresh Method's Selection / Options / + /// ExpectedGeneration / RequestId arguments. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotRefreshArgumentsTests + { + private static IServiceMessageContext Context => ServiceMessageContext.CreateEmpty(null!); + + private static ArrayOf Args(params Variant[] values) => values; + + [Test] + public void EmptyArgumentsDecodeToFullRefreshWithDefaults() + { + ServiceResult status = WotRefreshArguments.TryDecode( + Args(), Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Is.Empty); + Assert.That(request.ExpectedGeneration, Is.EqualTo(0u)); + Assert.That(request.RequestId, Is.EqualTo(string.Empty)); + Assert.That(request.Options, Is.Not.Null); + } + + [Test] + public void DecodesSelectionArrayOptionsGenerationAndRequestId() + { + var selector = new WoTResourceSelectorDataType + { + GroupId = "thingdescriptions", + ResourceId = "sensor", + Kind = WoTDocumentKindEnum.ThingDescription + }; + var options = new WoTRefreshOptionsDataType + { + Force = true, + DryRun = true, + Atomicity = WoTAtomicityEnum.PerGroup, + DeletePolicy = WoTDeletePolicyEnum.Retire, + IncludeDependents = true + }; + ArrayOf input = Args( + new Variant(new ExtensionObject[] { new ExtensionObject(selector) }), + new Variant(new ExtensionObject(options)), + new Variant(7u), + new Variant("req-42")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("sensor")); + Assert.That(request.Options.Force, Is.True); + Assert.That(request.Options.DryRun, Is.True); + Assert.That(request.Options.Atomicity, Is.EqualTo(WoTAtomicityEnum.PerGroup)); + Assert.That(request.Options.DeletePolicy, Is.EqualTo(WoTDeletePolicyEnum.Retire)); + Assert.That(request.Options.IncludeDependents, Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(7u)); + Assert.That(request.RequestId, Is.EqualTo("req-42")); + } + + [Test] + public void DecodesSelectionFromArrayOfExtensionObject() + { + var selectors = new ArrayOf(new[] + { + new ExtensionObject(new WoTResourceSelectorDataType { Xid = "/groups/g/resources/a" }), + new ExtensionObject(new WoTResourceSelectorDataType { Xid = "/groups/g/resources/b" }) + }); + ArrayOf input = Args(new Variant(selectors)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(2)); + Assert.That(request.Selection[1].Xid, Is.EqualTo("/groups/g/resources/b")); + } + + [Test] + public void RejectsSelectionOfWrongElementType() + { + var wrongSelection = new string[] { "not-a-selector" }; + ArrayOf input = Args(new Variant(wrongSelection)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsOptionsOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + new Variant("not-an-options-structure")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void RejectsExpectedGenerationOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant("five")); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AcceptsExpectedGenerationAsInt32() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + new Variant(9)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.ExpectedGeneration, Is.EqualTo(9u)); + } + + [Test] + public void RejectsRequestIdOfWrongType() + { + ArrayOf input = Args( + Variant.Null, + Variant.Null, + Variant.Null, + new Variant(123)); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, Context, out _); + + Assert.That(status.StatusCode.Code, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void DecodesBinaryEncodedSelectionBody() + { + IServiceMessageContext context = Context; + var selector = new WoTResourceSelectorDataType { ResourceId = "encoded" }; + byte[] encoded; + using (var encoder = new BinaryEncoder(context)) + { + selector.Encode(encoder); + encoded = encoder.CloseAndReturnBuffer()!; + } + var extension = new ExtensionObject( + Opc.Ua.WotCon.DataTypeIds.WoTResourceSelectorDataType, ByteString.From(encoded)); + ArrayOf input = Args(new Variant(new[] { extension })); + + ServiceResult status = WotRefreshArguments.TryDecode( + input, context, out WotRefreshRequest request); + + Assert.That(ServiceResult.IsGood(status), Is.True); + Assert.That(request.Selection, Has.Length.EqualTo(1)); + Assert.That(request.Selection[0].ResourceId, Is.EqualTo("encoded")); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj b/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj index ca9d37fdc7..eb70c22864 100644 --- a/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj +++ b/tests/Opc.Ua.WotCon.Tests/Opc.Ua.WotCon.Tests.csproj @@ -5,7 +5,7 @@ Opc.Ua.WotCon.Tests enable false - $(NoWarn);CS1591;CA2007;CA2000;CA1014 + $(NoWarn);CS1591;CA2007;CA2000;CA1014;CA1859;NUnit2046;NUnit4002 @@ -34,6 +34,7 @@ + diff --git a/tests/Opc.Ua.WotCon.Tests/Registry/FileWotRegistryStoreTests.cs b/tests/Opc.Ua.WotCon.Tests/Registry/FileWotRegistryStoreTests.cs new file mode 100644 index 0000000000..40e5a0833d --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Registry/FileWotRegistryStoreTests.cs @@ -0,0 +1,169 @@ +/* ======================================================================== + * 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.IO; +using System.Text; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Registry +{ + /// + /// Exercises the durable file-backed registry store: bounded atomic replace, + /// round-trip restore of resources / versions / bytes / state, and the + /// persistence of invalid documents with their failure state. + /// + [TestFixture] + public sealed class FileWotRegistryStoreTests + { + private string m_root = null!; + + [SetUp] + public void SetUp() + { + m_root = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "wot-store-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_root); + } + + [TearDown] + public void TearDown() + { + try + { + if (Directory.Exists(m_root)) + { + Directory.Delete(m_root, recursive: true); + } + } + catch (IOException) + { + } + } + + [Test] + public async Task Persist_And_Reload_RoundTripsResource() + { + var store = new FileWotRegistryStore(m_root); + using (var service = new WotRegistryService(store)) + { + await service.InitializeAsync(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a") + }); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingModels, + ResourceId = "m", + Kind = WoTDocumentKindEnum.ThingModel, + Content = TestMaterialization.Tm("urn:m") + }); + } + + var reloadStore = new FileWotRegistryStore(m_root); + using var reloaded = new WotRegistryService(reloadStore); + await reloaded.InitializeAsync(); + + WotResource? td = reloaded.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a"); + Assert.That(td, Is.Not.Null); + Assert.That(td!.Kind, Is.EqualTo(WoTDocumentKindEnum.ThingDescription)); + Assert.That(td.Versions.Length, Is.EqualTo(1)); + Assert.That( + Encoding.UTF8.GetString(td.Versions[0].Content.ToArray()), + Does.Contain("urn:a")); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingModels, "m"), Is.Not.Null); + } + + [Test] + public async Task InvalidDocument_SurvivesReload_WithFailureState() + { + var store = new FileWotRegistryStore(m_root); + using (var service = new WotRegistryService(store)) + { + await service.InitializeAsync(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "bad", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.InvalidJson() + }); + } + + var reloadStore = new FileWotRegistryStore(m_root); + using var reloaded = new WotRegistryService(reloadStore); + await reloaded.InitializeAsync(); + + WotResource bad = reloaded.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "bad")!; + Assert.That(bad.LoadState, Is.EqualTo(WoTLoadStateEnum.Failed)); + Assert.That(bad.Validation, Is.Not.Null); + Assert.That(bad.Validation!.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public async Task Upsert_OverwritesResourceAtomically() + { + var store = new FileWotRegistryStore(m_root); + using var service = new WotRegistryService(store); + await service.InitializeAsync(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a", "v1") + }); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a", "v2") + }); + + var reloadStore = new FileWotRegistryStore(m_root); + using var reloaded = new WotRegistryService(reloadStore); + await reloaded.InitializeAsync(); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a")! + .Versions.Length, + Is.EqualTo(2)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryLabelsServiceTests.cs b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryLabelsServiceTests.cs new file mode 100644 index 0000000000..7390cc34e1 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryLabelsServiceTests.cs @@ -0,0 +1,321 @@ +/* ======================================================================== + * 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.IO; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Registry +{ + /// + /// Exercises the xRegistry label (attribute) service API on the registry, + /// group and resource entities: add/update/remove, epoch optimistic + /// concurrency, key validation (reserved names, invalid/control/BIDI/path + /// characters, length), the per-entity label count bound, deterministic + /// ordinal ordering, and file-store persistence across a reload. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotRegistryLabelsServiceTests + { + private static readonly string[] s_alphaZebraLabels = ["alpha", "zebra"]; + private static readonly string[] s_zebraLabel = ["zebra"]; + + [Test] + public async Task AddResourceLabel_AddsThenUpdatesValue() + { + using var service = new WotRegistryService(); + await service.TryCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + + WotRegistryMutationResult added = await service.AddResourceLabelAsync( + "sensors", "a", "site", "seattle"); + Assert.That(added.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + WotResource? resource = service.Current.FindResource("sensors", "a"); + Assert.That(resource!.Labels["site"], Is.EqualTo("seattle")); + + WotRegistryMutationResult updated = await service.AddResourceLabelAsync( + "sensors", "a", "site", "portland"); + Assert.That(updated.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + resource = service.Current.FindResource("sensors", "a"); + Assert.That(resource!.Labels.Count, Is.EqualTo(1), + "Re-adding the same key must update in place, not duplicate."); + Assert.That(resource.Labels["site"], Is.EqualTo("portland")); + } + + [Test] + public async Task RemoveResourceLabel_RemovesKey() + { + using var service = new WotRegistryService(); + await service.TryCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + await service.AddResourceLabelAsync("sensors", "a", "site", "seattle"); + + WotRegistryMutationResult removed = await service.RemoveResourceLabelAsync( + "sensors", "a", "site"); + + Assert.That(removed.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + WotResource? resource = service.Current.FindResource("sensors", "a"); + Assert.That(resource!.Labels.ContainsKey("site"), Is.False); + } + + [Test] + public async Task RemoveResourceLabel_UnknownKey_Fails() + { + using var service = new WotRegistryService(); + await service.TryCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + + WotRegistryMutationResult removed = await service.RemoveResourceLabelAsync( + "sensors", "a", "missing"); + + Assert.That(removed.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public async Task AddResourceLabel_EpochMismatch_Rejected() + { + using var service = new WotRegistryService(); + (WotResource resource, _) = await service.GetOrCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + + WotRegistryMutationResult result = await service.AddResourceLabelAsync( + "sensors", "a", "site", "seattle", expectedEpoch: resource.Epoch + 999); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That( + service.Current.FindResource("sensors", "a")!.Labels.ContainsKey("site"), + Is.False); + } + + [Test] + public async Task AddResourceLabel_CorrectEpoch_Succeeds() + { + using var service = new WotRegistryService(); + (WotResource resource, _) = await service.GetOrCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + + WotRegistryMutationResult result = await service.AddResourceLabelAsync( + "sensors", "a", "site", "seattle", expectedEpoch: resource.Epoch); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + } + + [Test] + public void AddResourceLabel_MissingKey_Throws() + { + using var service = new WotRegistryService(); + ServiceResultException ex = Assert.ThrowsAsync( + async () => await service.AddResourceLabelAsync("sensors", "a", string.Empty, "x")); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [TestCase("Add\u0007Attribute")] // control character + [TestCase("a/b")] // path separator + [TestCase("a\u202Eb")] // BIDI override + [TestCase("AddAttribute")] // reserved container member + [TestCase("RemoveAttribute")] // reserved container member + public void AddResourceLabel_InvalidOrReservedKey_Throws(string key) + { + using var service = new WotRegistryService(); + Assert.ThrowsAsync( + async () => await service.AddResourceLabelAsync("sensors", "a", key, "x")); + } + + [Test] + public void AddResourceLabel_KeyTooLong_Throws() + { + using var service = new WotRegistryService(); + string longKey = new string('k', service.Bounds.MaxLabelKeyLength + 1); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await service.AddResourceLabelAsync("sensors", "a", longKey, "x")); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public void AddResourceLabel_ValueTooLong_Throws() + { + using var service = new WotRegistryService(); + string longValue = new string('v', service.Bounds.MaxLabelValueLength + 1); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await service.AddResourceLabelAsync("sensors", "a", "k", longValue)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument)); + } + + [Test] + public async Task AddResourceLabel_ExceedsMaxLabelsPerEntity_Throws() + { + var bounds = new WotRegistryPersistenceBounds { MaxLabelsPerEntity = 2 }; + using var service = new WotRegistryService(bounds: bounds); + await service.TryCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + await service.AddResourceLabelAsync("sensors", "a", "k1", "v1"); + await service.AddResourceLabelAsync("sensors", "a", "k2", "v2"); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await service.AddResourceLabelAsync("sensors", "a", "k3", "v3")); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + + // Updating an existing key must still be allowed at the limit. + WotRegistryMutationResult update = await service.AddResourceLabelAsync( + "sensors", "a", "k1", "v1-updated"); + Assert.That(update.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + } + + [Test] + public async Task GroupLabels_AddUpdateRemove_EpochAndOrdering() + { + using var service = new WotRegistryService(); + WotResourceGroup group = await service.GetOrCreateGroupAsync( + "sensors", WoTDocumentKindEnum.ThingDescription); + + await service.AddGroupLabelAsync("sensors", "zebra", "1"); + await service.AddGroupLabelAsync("sensors", "alpha", "2"); + WotResourceGroup? updatedGroup = service.Current.FindGroup("sensors"); + Assert.That(updatedGroup!.Labels.Keys, Is.EqualTo(s_alphaZebraLabels), + "Labels must enumerate in deterministic ordinal key order."); + + WotRegistryMutationResult mismatched = await service.RemoveGroupLabelAsync( + "sensors", "alpha", expectedEpoch: group.Epoch + 1); + Assert.That(mismatched.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + + WotRegistryMutationResult removed = await service.RemoveGroupLabelAsync( + "sensors", "alpha"); + Assert.That(removed.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That( + service.Current.FindGroup("sensors")!.Labels.Keys, + Is.EqualTo(s_zebraLabel)); + } + + [Test] + public void AddGroupLabel_UnknownGroup_Fails() + { + using var service = new WotRegistryService(); + WotRegistryMutationResult result = service.AddGroupLabelAsync( + "missing", "k", "v").AsTask().GetAwaiter().GetResult(); + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public async Task RegistryLabels_AddUpdateRemove_UsesSnapshotGenerationAsEpoch() + { + using var service = new WotRegistryService(); + long generationBefore = service.Current.Generation; + + WotRegistryMutationResult added = await service.AddRegistryLabelAsync( + "environment", "production", expectedEpoch: generationBefore); + Assert.That(added.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That(service.Current.Labels["environment"], Is.EqualTo("production")); + + WotRegistryMutationResult mismatched = await service.AddRegistryLabelAsync( + "environment", "staging", expectedEpoch: generationBefore); + Assert.That(mismatched.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected), + "The registry epoch is the snapshot generation, which already advanced."); + + WotRegistryMutationResult removed = await service.RemoveRegistryLabelAsync("environment"); + Assert.That(removed.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That(service.Current.Labels.ContainsKey("environment"), Is.False); + } + + [Test] + public async Task LabelMutations_AreProjectionOnly_AndDoNotChangeResourceContent() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a") + }); + WotRegistryChangedEventArgs? captured = null; + service.Changed += (_, e) => captured = e; + + await service.AddResourceLabelAsync( + WotRegistryGroups.ThingDescriptions, "a", "site", "seattle"); + + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.ProjectionOnly, Is.True, + "Label-only mutations must not re-trigger materialization."); + } + + [Test] + public async Task FileStore_PersistsLabels_AcrossReload() + { + string root = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "wot-labels-store-" + System.Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + try + { + var store = new FileWotRegistryStore(root); + using (var service = new WotRegistryService(store)) + { + await service.InitializeAsync(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a") + }); + await service.AddResourceLabelAsync( + WotRegistryGroups.ThingDescriptions, "a", "site", "seattle"); + await service.AddGroupLabelAsync( + WotRegistryGroups.ThingDescriptions, "owner", "team-iot"); + await service.AddRegistryLabelAsync("environment", "production"); + } + + var reloadStore = new FileWotRegistryStore(root); + using var reloaded = new WotRegistryService(reloadStore); + await reloaded.InitializeAsync(); + + Assert.That(reloaded.Current.Labels["environment"], Is.EqualTo("production")); + Assert.That( + reloaded.Current.FindGroup(WotRegistryGroups.ThingDescriptions)! + .Labels["owner"], + Is.EqualTo("team-iot")); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a")! + .Labels["site"], + Is.EqualTo("seattle")); + } + finally + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryServiceCrudTests.cs b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryServiceCrudTests.cs new file mode 100644 index 0000000000..19f29fe81a --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryServiceCrudTests.cs @@ -0,0 +1,172 @@ +/* ======================================================================== + * 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.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Registry +{ + /// + /// Exercises the xRegistry CRUD additions used by the OPC UA management + /// Methods: group create/delete, resource placeholder create-or-get and + /// document validation. + /// + [TestFixture] + [Category("WotCon")] + public sealed class WotRegistryServiceCrudTests + { + [Test] + public async Task TryCreateGroup_CreatesThenFailsOnDuplicate() + { + using var service = new WotRegistryService(); + + WotResourceGroup? first = await service.TryCreateGroupAsync( + "sensors", WoTDocumentKindEnum.ThingDescription); + WotResourceGroup? second = await service.TryCreateGroupAsync( + "sensors", WoTDocumentKindEnum.ThingDescription); + + Assert.That(first, Is.Not.Null); + Assert.That(first!.GroupId, Is.EqualTo("sensors")); + Assert.That(second, Is.Null, "A second CreateGroup with the same id must fail."); + } + + [Test] + public async Task DeleteGroup_RemovesGroupAndResources() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = "sensors", + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a") + }); + + WotRegistryMutationResult result = await service.DeleteGroupAsync("sensors"); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That(service.Current.FindGroup("sensors"), Is.Null); + Assert.That(service.Current.FindResource("sensors", "a"), Is.Null); + } + + [Test] + public async Task DeleteGroup_WithWrongEpoch_Rejected() + { + using var service = new WotRegistryService(); + WotResourceGroup group = await service.GetOrCreateGroupAsync( + "sensors", WoTDocumentKindEnum.ThingDescription); + + WotRegistryMutationResult result = await service.DeleteGroupAsync( + "sensors", expectedEpoch: group.Epoch + 999); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(service.Current.FindGroup("sensors"), Is.Not.Null); + } + + [Test] + public async Task GetOrCreateResource_CreatesPlaceholderThenReturnsExisting() + { + using var service = new WotRegistryService(); + + (WotResource created, bool createdFlag) = await service.GetOrCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + (WotResource fetched, bool fetchedFlag) = await service.GetOrCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + + Assert.That(createdFlag, Is.True); + Assert.That(fetchedFlag, Is.False); + Assert.That(created.Versions, Is.Empty, "A placeholder resource carries no versions."); + Assert.That(created.DefaultVersion, Is.Null); + Assert.That(fetched.ResourceId, Is.EqualTo("a")); + } + + [Test] + public async Task TryCreateResource_FailsWhenResourceExists() + { + using var service = new WotRegistryService(); + await service.TryCreateResourceAsync("sensors", "a", WoTDocumentKindEnum.ThingDescription); + + WotResource? duplicate = await service.TryCreateResourceAsync( + "sensors", "a", WoTDocumentKindEnum.ThingDescription); + + Assert.That(duplicate, Is.Null); + } + + [Test] + public async Task Validate_ValidDocument_ReportsSuccess() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = "sensors", + ResourceId = "a", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:a") + }); + + WoTValidationOutcomeDataType outcome = await service.ValidateResourceAsync("sensors", "a"); + + Assert.That(outcome.FormatValidated, Is.True); + Assert.That(outcome.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Success)); + WotResource? resource = service.Current.FindResource("sensors", "a"); + Assert.That(resource!.Validation, Is.Not.Null); + Assert.That(resource.Validation!.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Success)); + } + + [Test] + public async Task Validate_InvalidDocument_ReportsFailure() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(new WotUpsertResourceRequest + { + GroupId = "sensors", + ResourceId = "bad", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.InvalidJson() + }); + + WoTValidationOutcomeDataType outcome = await service.ValidateResourceAsync("sensors", "bad"); + + Assert.That(outcome.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + } + + [Test] + public void Validate_NoDefaultVersion_Throws() + { + using var service = new WotRegistryService(); + _ = service.TryCreateResourceAsync( + "sensors", "empty", WoTDocumentKindEnum.ThingDescription).AsTask().GetAwaiter().GetResult(); + + ServiceResultException ex = Assert.ThrowsAsync( + async () => await service.ValidateResourceAsync("sensors", "empty")); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryServiceTests.cs b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryServiceTests.cs new file mode 100644 index 0000000000..4354f986ef --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryServiceTests.cs @@ -0,0 +1,305 @@ +/* ======================================================================== + * 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.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Registry +{ + /// + /// Exercises the stable registry service: CRUD, versioning, default and + /// enabled state, invalid-document retention, unchanged idempotency, epoch + /// concurrency and persistence bounds. + /// + [TestFixture] + public sealed class WotRegistryServiceTests + { + private static WotUpsertResourceRequest TdRequest( + string resourceId, byte[] content, bool setDefault = true) + => new WotUpsertResourceRequest + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = content, + SetAsDefault = setDefault + }; + + [Test] + public async Task Upsert_CreatesResourceAndBumpsGeneration() + { + using var service = new WotRegistryService(); + byte[] doc = TestMaterialization.Td("urn:a"); + + WotRegistryMutationResult result = await service.UpsertResourceAsync( + TdRequest("a", doc)); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That(result.Generation, Is.GreaterThan(0)); + WotResource? resource = service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a"); + Assert.That(resource, Is.Not.Null); + Assert.That(resource!.Versions.Length, Is.EqualTo(1)); + Assert.That(resource.DefaultVersionId, Is.EqualTo(resource.Versions[0].VersionId)); + Assert.That(resource.Kind, Is.EqualTo(WoTDocumentKindEnum.ThingDescription)); + } + + [Test] + public async Task Upsert_SameContent_ReturnsUnchanged() + { + using var service = new WotRegistryService(); + byte[] doc = TestMaterialization.Td("urn:a"); + await service.UpsertResourceAsync(TdRequest("a", doc)); + long generation = service.Current.Generation; + + WotRegistryMutationResult second = await service.UpsertResourceAsync( + TdRequest("a", doc)); + + Assert.That(second.Outcome, Is.EqualTo(WoTOutcomeEnum.Unchanged)); + Assert.That(service.Current.Generation, Is.EqualTo(generation), + "An unchanged upload must not advance the registry generation."); + Assert.That( + service.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a")! + .Versions.Length, + Is.EqualTo(1)); + } + + [Test] + public async Task Upsert_NewContent_AddsVersion() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a", "v1"))); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a", "v2"))); + + WotResource resource = service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a")!; + Assert.That(resource.Versions.Length, Is.EqualTo(2)); + Assert.That(resource.DefaultVersionId, Is.EqualTo(resource.Versions[1].VersionId)); + } + + [Test] + public async Task InvalidDocument_IsStoredWithFailureState() + { + using var service = new WotRegistryService(); + + WotRegistryMutationResult result = await service.UpsertResourceAsync( + TdRequest("bad", TestMaterialization.InvalidJson())); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Warning)); + WotResource resource = service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "bad")!; + Assert.That(resource.LoadState, Is.EqualTo(WoTLoadStateEnum.Failed)); + Assert.That(resource.Validation, Is.Not.Null); + Assert.That(resource.Validation!.FormatOutcome, Is.EqualTo(WoTOutcomeEnum.Failed)); + Assert.That(resource.Versions.Length, Is.EqualTo(1), + "The invalid document must still be stored."); + } + + [Test] + public async Task Upsert_TooLarge_IsRejectedAndNotStored() + { + var bounds = new WotRegistryPersistenceBounds { MaxDocumentBytes = 32 }; + using var service = new WotRegistryService(bounds: bounds); + byte[] big = new byte[64]; + + WotRegistryMutationResult result = await service.UpsertResourceAsync( + TdRequest("big", big)); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "big"), Is.Null); + } + + [Test] + public async Task MaxGroups_ImplicitCreateViaGetOrCreateResource_IsRejected() + { + var bounds = new WotRegistryPersistenceBounds { MaxGroups = 1 }; + using var service = new WotRegistryService(bounds: bounds); + // Fill the single group slot via the well-known Thing Description group. + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + Assert.That(service.Current.Groups.Count, Is.EqualTo(1)); + + // Implicitly creating a placeholder in a new group would exceed + // MaxGroups and must be rejected identically to the explicit + // group-create APIs (BadTooManyOperations). + ServiceResultException ex = Assert.ThrowsAsync(async () => + await service.GetOrCreateResourceAsync("sensors", "r", WoTDocumentKindEnum.ThingDescription)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + Assert.That(service.Current.FindGroup("sensors"), Is.Null, + "The over-limit implicit group must not be created."); + } + + [Test] + public async Task MaxGroups_ImplicitCreateViaTryCreateResource_IsRejected() + { + var bounds = new WotRegistryPersistenceBounds { MaxGroups = 1 }; + using var service = new WotRegistryService(bounds: bounds); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + + ServiceResultException ex = Assert.ThrowsAsync(async () => + await service.TryCreateResourceAsync("sensors", "r", WoTDocumentKindEnum.ThingDescription)); + Assert.That(ex.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations)); + Assert.That(service.Current.FindGroup("sensors"), Is.Null); + } + + [Test] + public async Task MaxGroups_ImplicitCreateViaUpsert_IsRejected() + { + var bounds = new WotRegistryPersistenceBounds { MaxGroups = 1 }; + using var service = new WotRegistryService(bounds: bounds); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + + // An upsert whose target group does not yet exist would implicitly + // create a second group; the bound must reject it. + WotRegistryMutationResult result = await service.UpsertResourceAsync( + new WotUpsertResourceRequest + { + GroupId = "sensors", + ResourceId = "r", + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td("urn:r") + }); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + Assert.That(service.Current.FindGroup("sensors"), Is.Null); + } + + [Test] + public async Task MaxGroups_AllowsAnotherResourceInExistingGroup() + { + var bounds = new WotRegistryPersistenceBounds { MaxGroups = 1 }; + using var service = new WotRegistryService(bounds: bounds); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + + // Creating another resource in the SAME existing group creates no new + // group, so it must not be blocked by MaxGroups. + (WotResource _, bool created) = await service.GetOrCreateResourceAsync( + WotRegistryGroups.ThingDescriptions, "b", WoTDocumentKindEnum.ThingDescription); + + Assert.That(created, Is.True); + Assert.That(service.Current.Groups.Count, Is.EqualTo(1)); + } + + [Test] + public async Task VersionRetention_TrimsOldestBeyondBound() + { + var bounds = new WotRegistryPersistenceBounds { MaxVersionsPerResource = 3 }; + using var service = new WotRegistryService(bounds: bounds); + for (int i = 0; i < 5; i++) + { + await service.UpsertResourceAsync( + TdRequest("a", TestMaterialization.Td("urn:a", "v" + i))); + } + + WotResource resource = service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a")!; + Assert.That(resource.Versions.Length, Is.EqualTo(3), + "Version retention must trim the oldest versions."); + } + + [Test] + public async Task SetDefaultVersion_SwitchesActiveDefault() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a", "v1"))); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a", "v2"))); + WotResource resource = service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a")!; + string firstVersion = resource.Versions[0].VersionId; + + WotRegistryMutationResult result = await service.SetDefaultVersionAsync( + WotRegistryGroups.ThingDescriptions, "a", firstVersion, resource.Epoch); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That( + service.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a")! + .DefaultVersionId, + Is.EqualTo(firstVersion)); + } + + [Test] + public async Task SetDefaultVersion_WrongEpoch_IsRejected() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + WotResource resource = service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a")!; + + WotRegistryMutationResult result = await service.SetDefaultVersionAsync( + WotRegistryGroups.ThingDescriptions, "a", + resource.Versions[0].VersionId, expectedEpoch: resource.Epoch + 999); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Rejected)); + } + + [Test] + public async Task SetEnabled_TogglesEnabledState() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + + await service.SetEnabledAsync(WotRegistryGroups.ThingDescriptions, "a", enabled: false); + + Assert.That( + service.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a")!.Enabled, + Is.False); + } + + [Test] + public async Task Delete_RemovesResource() + { + using var service = new WotRegistryService(); + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + + WotRegistryMutationResult result = await service.DeleteResourceAsync( + WotRegistryGroups.ThingDescriptions, "a"); + + Assert.That(result.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That(service.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a"), Is.Null); + } + + [Test] + public async Task Changed_RaisedForContentMutation() + { + using var service = new WotRegistryService(); + WotRegistryChangedEventArgs? captured = null; + service.Changed += (_, e) => captured = e; + + await service.UpsertResourceAsync(TdRequest("a", TestMaterialization.Td("urn:a"))); + + Assert.That(captured, Is.Not.Null); + Assert.That(captured!.ProjectionOnly, Is.False); + Assert.That(captured.ChangedResourceXids, Has.Count.EqualTo(1)); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryTransactionTests.cs b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryTransactionTests.cs new file mode 100644 index 0000000000..ac2c9c7dab --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/Registry/WotRegistryTransactionTests.cs @@ -0,0 +1,310 @@ +/* ======================================================================== + * 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.Immutable; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.WotCon.Server.Registry; + +namespace Opc.Ua.WotCon.Tests.Registry +{ + /// + /// Fault-injection tests for the registry's transactional store commit + /// contract. A mutation must be made durable atomically before the + /// new snapshot is published () or a + /// event is raised. When a commit + /// fails: the current snapshot stays the previous generation, no change event + /// is raised, a retry re-attempts persistence, and a restart never observes + /// the partially-applied mutation. + /// + [TestFixture] + public sealed class WotRegistryTransactionTests + { + private string m_root = null!; + + [SetUp] + public void SetUp() + { + m_root = Path.Combine( + TestContext.CurrentContext.TestDirectory, + "wot-tx-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(m_root); + } + + [TearDown] + public void TearDown() + { + try + { + if (Directory.Exists(m_root)) + { + Directory.Delete(m_root, recursive: true); + } + } + catch (IOException) + { + } + } + + private static WotUpsertResourceRequest TdRequest(string resourceId, string id) + => new() + { + GroupId = WotRegistryGroups.ThingDescriptions, + ResourceId = resourceId, + Kind = WoTDocumentKindEnum.ThingDescription, + Content = TestMaterialization.Td(id) + }; + + [Test] + public async Task CommitFailure_LeavesCurrentUnchanged_AndRaisesNoEvent() + { + var store = new FaultInjectingWotRegistryStore(new InMemoryWotRegistryStore()); + using var service = new WotRegistryService(store); + await service.InitializeAsync(); + + // Seed a first, successful mutation so there is a prior generation. + await service.UpsertResourceAsync(TdRequest("a", "urn:a")); + long generationBefore = service.Current.Generation; + + int changedCount = 0; + service.Changed += (_, _) => changedCount++; + + // Arm the injected failure: the next commit throws before it persists. + store.FailNextCommit = true; + Assert.ThrowsAsync( + async () => await service.UpsertResourceAsync(TdRequest("b", "urn:b"))); + + // Current must still be the previous generation: no partial publish. + Assert.That(service.Current.Generation, Is.EqualTo(generationBefore), + "A failed commit must not advance the published generation."); + Assert.That( + service.Current.FindResource(WotRegistryGroups.ThingDescriptions, "b"), + Is.Null, "A failed commit must not publish the new resource."); + Assert.That( + service.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a"), + Is.Not.Null, "The prior generation must remain intact after a failed commit."); + Assert.That(changedCount, Is.EqualTo(0), + "A failed commit must not raise a Changed event."); + } + + [Test] + public async Task CommitFailure_ThenRetry_Persists_AndRaisesExactlyOneEvent() + { + var store = new FaultInjectingWotRegistryStore(new InMemoryWotRegistryStore()); + using var service = new WotRegistryService(store); + await service.InitializeAsync(); + + int changedCount = 0; + service.Changed += (_, _) => changedCount++; + + store.FailNextCommit = true; + Assert.ThrowsAsync( + async () => await service.UpsertResourceAsync(TdRequest("a", "urn:a"))); + Assert.That(changedCount, Is.EqualTo(0)); + + // Retry after the fault clears: the same mutation now commits and the + // resource becomes visible with a single change notification. + store.FailNextCommit = false; + WotRegistryMutationResult retry = await service.UpsertResourceAsync( + TdRequest("a", "urn:a")); + + Assert.That(retry.Outcome, Is.EqualTo(WoTOutcomeEnum.Success)); + Assert.That( + service.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a"), + Is.Not.Null); + Assert.That(changedCount, Is.EqualTo(1), + "Exactly one Changed event must be raised, only on the successful commit."); + Assert.That(store.CommitAttempts, Is.EqualTo(2), + "The retry must re-attempt persistence."); + } + + [Test] + public async Task CommitFailure_RestartSeesNoPartialData() + { + // First service instance persists 'a', then fails to commit 'b'. + var store = new FaultInjectingWotRegistryStore(new FileWotRegistryStore(m_root)); + using (var service = new WotRegistryService(store)) + { + await service.InitializeAsync(); + await service.UpsertResourceAsync(TdRequest("a", "urn:a")); + + store.FailNextCommit = true; + Assert.ThrowsAsync( + async () => await service.UpsertResourceAsync(TdRequest("b", "urn:b"))); + } + + // Restart over the same folder with a fresh store/service: only the + // durably committed 'a' is restored; 'b' was never persisted. + using var reloaded = new WotRegistryService(new FileWotRegistryStore(m_root)); + await reloaded.InitializeAsync(); + + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a"), + Is.Not.Null, "The committed resource must survive a restart."); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "b"), + Is.Null, "A resource whose commit failed must never appear after a restart."); + } + + [Test] + public async Task FileStore_Load_ReadsOnlyCommittedGeneration_IgnoringStagedFiles() + { + using (var service = new WotRegistryService(new FileWotRegistryStore(m_root))) + { + await service.InitializeAsync(); + await service.UpsertResourceAsync(TdRequest("a", "urn:a")); + } + + // Simulate a crash after staging but before the atomic manifest switch: + // an orphan version blob and a temp manifest exist, but manifest.json + // still points at the committed generation. + string blobsDir = Path.Combine(m_root, "blobs"); + Directory.CreateDirectory(blobsDir); + File.WriteAllBytes( + Path.Combine(blobsDir, new string('e', 40) + ".bin"), + TestMaterialization.Td("urn:staged")); + File.WriteAllText( + Path.Combine(m_root, "manifest.json.tmp-" + Guid.NewGuid().ToString("N")), + "{ staged, not committed"); + + using var reloaded = new WotRegistryService(new FileWotRegistryStore(m_root)); + await reloaded.InitializeAsync(); + + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a"), + Is.Not.Null, "Load must restore the committed generation."); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "staged"), + Is.Null, "Load must ignore staged, not-yet-committed data."); + } + + [Test] + public async Task ProjectionResults_AreDurablyCommitted_AndSurviveRestart() + { + string activeVersionId; + using (var service = new WotRegistryService(new FileWotRegistryStore(m_root))) + { + await service.InitializeAsync(); + WotRegistryMutationResult upsert = await service.UpsertResourceAsync( + TdRequest("a", "urn:a")); + activeVersionId = upsert.Resource!.DefaultVersionId!; + + await service.ApplyProjectionResultsAsync(new[] + { + new WotResourceProjection( + WotRegistryGroups.ThingDescriptions, + "a", + WoTLoadStateEnum.Active, + activeVersionId, + refreshGeneration: 7, + materializedNodeCount: 5, + rootNodeId: new NodeId(5000, 1), + validation: null, + diagnostics: ImmutableArray.Empty, + lastRefreshTime: DateTime.UtcNow) + }); + } + + using var reloaded = new WotRegistryService(new FileWotRegistryStore(m_root)); + await reloaded.InitializeAsync(); + + WotResource restored = reloaded.Current.FindResource( + WotRegistryGroups.ThingDescriptions, "a")!; + Assert.That(restored.LoadState, Is.EqualTo(WoTLoadStateEnum.Active), + "Projection load state must be durably committed."); + Assert.That(restored.ActiveVersionId, Is.EqualTo(activeVersionId)); + Assert.That(restored.RefreshGeneration, Is.EqualTo(7u)); + Assert.That(restored.MaterializedNodeCount, Is.EqualTo(5)); + Assert.That(restored.RootNodeId, Is.EqualTo(new NodeId(5000, 1))); + } + + [Test] + public async Task InMemoryStore_CommitFailure_LoadReturnsPreviousGeneration() + { + var store = new FaultInjectingWotRegistryStore(new InMemoryWotRegistryStore()); + using (var service = new WotRegistryService(store)) + { + await service.InitializeAsync(); + await service.UpsertResourceAsync(TdRequest("a", "urn:a")); + + store.FailNextCommit = true; + Assert.ThrowsAsync( + async () => await service.UpsertResourceAsync(TdRequest("b", "urn:b"))); + } + + // A brand new service over the same in-memory store instance loads the + // last committed generation only (the failed 'b' commit is absent). + using var reloaded = new WotRegistryService(store); + await reloaded.InitializeAsync(); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "a"), + Is.Not.Null); + Assert.That( + reloaded.Current.FindResource(WotRegistryGroups.ThingDescriptions, "b"), + Is.Null); + } + + /// + /// An decorator that can be armed to throw + /// on the next (before delegating to the inner + /// store), so a persistence failure can be injected deterministically. + /// + private sealed class FaultInjectingWotRegistryStore : IWotRegistryStore + { + public FaultInjectingWotRegistryStore(IWotRegistryStore inner) + { + m_inner = inner; + } + + public bool FailNextCommit { get; set; } + + public int CommitAttempts { get; private set; } + + public ValueTask LoadAsync( + CancellationToken cancellationToken = default) + => m_inner.LoadAsync(cancellationToken); + + public ValueTask CommitAsync( + WotRegistrySnapshot snapshot, CancellationToken cancellationToken = default) + { + CommitAttempts++; + if (FailNextCommit) + { + throw new InvalidOperationException("Injected commit failure."); + } + return m_inner.CommitAsync(snapshot, cancellationToken); + } + + private readonly IWotRegistryStore m_inner; + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/SimulatedWotAssetProviderTests.cs b/tests/Opc.Ua.WotCon.Tests/SimulatedWotAssetProviderTests.cs index 592eec7ac9..6c6191d682 100644 --- a/tests/Opc.Ua.WotCon.Tests/SimulatedWotAssetProviderTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/SimulatedWotAssetProviderTests.cs @@ -60,7 +60,7 @@ public void SetUp() m_voltageTag = new WotPropertyTag( "Voltage", new NodeId(1u, 2), - DataTypeIds.Double, + Ua.DataTypeIds.Double, ValueRanks.Scalar, readOnly: false, observable: true, @@ -137,8 +137,8 @@ public async Task InvokeActionEchoesInputsToOutputs() var actionTag = new WotActionTag( "Echo", new NodeId(2u, 2), - [new Argument { Name = "in1", DataType = DataTypeIds.Int64 }], - [new Argument { Name = "out1", DataType = DataTypeIds.Int64 }], + [new Argument { Name = "in1", DataType = Ua.DataTypeIds.Int64 }], + [new Argument { Name = "out1", DataType = Ua.DataTypeIds.Int64 }], form: null); var outputs = new Variant[1]; diff --git a/tests/Opc.Ua.WotCon.Tests/TestMaterialization.cs b/tests/Opc.Ua.WotCon.Tests/TestMaterialization.cs new file mode 100644 index 0000000000..ceb708ad2f --- /dev/null +++ b/tests/Opc.Ua.WotCon.Tests/TestMaterialization.cs @@ -0,0 +1,90 @@ +/* ======================================================================== + * 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.Text; + +namespace Opc.Ua.WotCon.Tests +{ + /// + /// Shared builders for WoT test documents (Thing Descriptions / Thing + /// Models) used across the registry and materialization test fixtures. + /// + internal static class TestMaterialization + { + /// Builds a minimal Thing Description document. + public static byte[] Td(string id, string variant = "1", params string[] extendsHrefs) + { + var builder = new StringBuilder(); + builder.Append("{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\","); + builder.Append("\"@type\":\"uav:object\","); + builder.Append("\"id\":\"").Append(id).Append("\","); + builder.Append("\"title\":\"").Append(id).Append('-').Append(variant).Append("\","); + builder.Append("\"properties\":{\"value\":{\"type\":\"number\",\"forms\":[{\"href\":\"x\"}]}}"); + AppendLinks(builder, extendsHrefs); + builder.Append('}'); + return Encoding.UTF8.GetBytes(builder.ToString()); + } + + /// Builds a minimal Thing Model document. + public static byte[] Tm(string id, string variant = "1", params string[] extendsHrefs) + { + var builder = new StringBuilder(); + builder.Append("{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\","); + builder.Append("\"@type\":\"tm:ThingModel\","); + builder.Append("\"id\":\"").Append(id).Append("\","); + builder.Append("\"title\":\"").Append(id).Append('-').Append(variant).Append("\","); + builder.Append("\"properties\":{\"value\":{\"type\":\"number\",\"forms\":[{\"href\":\"x\"}]}}"); + AppendLinks(builder, extendsHrefs); + builder.Append('}'); + return Encoding.UTF8.GetBytes(builder.ToString()); + } + + /// Builds a syntactically invalid JSON document. + public static byte[] InvalidJson() => Encoding.UTF8.GetBytes("{ not valid json "); + + private static void AppendLinks(StringBuilder builder, string[] extendsHrefs) + { + if (extendsHrefs is not { Length: > 0 }) + { + return; + } + builder.Append(",\"links\":["); + for (int i = 0; i < extendsHrefs.Length; i++) + { + if (i > 0) + { + builder.Append(','); + } + builder.Append("{\"rel\":\"tm:extends\",\"href\":\"") + .Append(extendsHrefs[i]).Append("\"}"); + } + builder.Append(']'); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Tests/WotActionMapperTests.cs b/tests/Opc.Ua.WotCon.Tests/WotActionMapperTests.cs index ec4ee85689..f965238647 100644 --- a/tests/Opc.Ua.WotCon.Tests/WotActionMapperTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/WotActionMapperTests.cs @@ -61,7 +61,7 @@ public void BuildArgumentsForFlatObjectMapsEachPropertyToOneArgument() Assert.That(arguments, Has.Count.EqualTo(2)); Assert.That(arguments[0].Name, Is.EqualTo("target")); - Assert.That(arguments[0].DataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(arguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(arguments[0].ValueRank, Is.EqualTo(ValueRanks.Scalar)); // Rec 4: assert the full description text so format / framing mutations // (square brackets, separator spaces, "min=, max=" comma) are caught. @@ -69,7 +69,7 @@ public void BuildArgumentsForFlatObjectMapsEachPropertyToOneArgument() arguments[0].Description.Text, Is.EqualTo("Target temperature [degree Celsius] (min=10, max=30)")); Assert.That(arguments[1].Name, Is.EqualTo("confirm")); - Assert.That(arguments[1].DataType, Is.EqualTo(DataTypeIds.Boolean)); + Assert.That(arguments[1].DataType, Is.EqualTo(Ua.DataTypeIds.Boolean)); Assert.That(arguments[1].ValueRank, Is.EqualTo(ValueRanks.Scalar)); Assert.That(arguments[1].Description.IsNull, Is.True, "Members without description/unit/bounds should have a null LocalizedText."); @@ -181,7 +181,7 @@ public void BuildArgumentsForNonObjectSchemaCollapsesToBaseDataType() IReadOnlyList arguments = WotActionMapper.BuildArguments(schema); Assert.That(arguments, Has.Count.EqualTo(1)); - Assert.That(arguments[0].DataType, Is.EqualTo(DataTypeIds.BaseDataType)); + Assert.That(arguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.BaseDataType)); Assert.That(arguments[0].Name, Is.EqualTo("rawString")); Assert.That(arguments[0].Description.Text, Is.EqualTo("raw payload")); } @@ -223,7 +223,7 @@ public void BuildArgumentsForEmptyPropertiesDictionaryCollapsesToBaseDataType() IReadOnlyList arguments = WotActionMapper.BuildArguments(schema); Assert.That(arguments, Has.Count.EqualTo(1)); - Assert.That(arguments[0].DataType, Is.EqualTo(DataTypeIds.BaseDataType)); + Assert.That(arguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.BaseDataType)); Assert.That(arguments[0].ValueRank, Is.EqualTo(ValueRanks.Scalar)); Assert.That(arguments[0].Name, Is.EqualTo("empty")); } @@ -261,7 +261,7 @@ public void BuildArgumentsForArrayMemberMarksOneDimensional() IReadOnlyList arguments = WotActionMapper.BuildArguments(schema); Assert.That(arguments, Has.Count.EqualTo(1)); - Assert.That(arguments[0].DataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(arguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(arguments[0].ValueRank, Is.EqualTo(ValueRanks.OneDimension)); } @@ -288,7 +288,7 @@ public void BuildArgumentsForArrayOfObjectMemberFallsBackToBaseDataType() IReadOnlyList arguments = WotActionMapper.BuildArguments(schema); Assert.That(arguments, Has.Count.EqualTo(1)); - Assert.That(arguments[0].DataType, Is.EqualTo(DataTypeIds.BaseDataType)); + Assert.That(arguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.BaseDataType)); Assert.That(arguments[0].ValueRank, Is.EqualTo(ValueRanks.OneDimension)); } @@ -310,7 +310,7 @@ public void BuildArgumentsForArrayMemberWithoutItemsFallsBackToBaseDataType() IReadOnlyList arguments = WotActionMapper.BuildArguments(schema); Assert.That(arguments, Has.Count.EqualTo(1)); - Assert.That(arguments[0].DataType, Is.EqualTo(DataTypeIds.BaseDataType)); + Assert.That(arguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.BaseDataType)); Assert.That(arguments[0].ValueRank, Is.EqualTo(ValueRanks.OneDimension)); } } diff --git a/tests/Opc.Ua.WotCon.Tests/WotConnectivityNodeManagerTests.cs b/tests/Opc.Ua.WotCon.Tests/WotConnectivityNodeManagerTests.cs index a110faca2c..5310106ffa 100644 --- a/tests/Opc.Ua.WotCon.Tests/WotConnectivityNodeManagerTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/WotConnectivityNodeManagerTests.cs @@ -313,7 +313,7 @@ public async Task RebuildMaterialisesPropertyVariableAndAssetEndpoint() Assert.That(entry.Properties, Has.Count.EqualTo(1)); (BaseDataVariableState variable, WotPropertyTag tag) = entry.Properties.Values.First(); Assert.That(tag.Name, Is.EqualTo("Voltage")); - Assert.That(variable.DataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(variable.DataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(variable.ValueRank, Is.EqualTo(ValueRanks.Scalar)); Assert.That(variable.BrowseName.Name, Is.EqualTo("Voltage")); Assert.That(variable.DisplayName.Text, Is.EqualTo("Voltage")); @@ -387,6 +387,49 @@ await harness.Registry.RebuildAsync( Assert.That(result.Value.AsBoxedObject(), Is.EqualTo(12.3)); } + [Test] + public async Task LegacyAssetIsMirroredIntoV2RegistryWithoutDivergence() + { + using var harness = new ManagerHarness( + _tempFolder, new SimulatedWotAssetProviderFactory()); + using var registry = new Opc.Ua.WotCon.Server.Registry.WotRegistryService(); + harness.Options.RegistryBridge = registry; + await harness.StartAsync().ConfigureAwait(false); + + (_, NodeId assetId) = await harness.Registry + .CreateAssetAsync("asset-001", CancellationToken.None).ConfigureAwait(false); + AssetEntry entry = harness.Registry.FindByNodeId(assetId)!; + var td = new ThingDescription + { + Name = "asset-001", + Base = "sim://opcua.test/wot/asset-001", + Properties = new Dictionary + { + ["Voltage"] = new WotProperty { Type = "number" } + } + }; + await harness.Registry.RebuildAsync( + entry, td, persistOnSuccess: false, CancellationToken.None).ConfigureAwait(false); + + Opc.Ua.WotCon.Server.Registry.WotResource? resource = registry.Current.FindResource( + Opc.Ua.WotCon.Server.Registry.WotRegistryGroups.ThingDescriptions, "asset-001"); + Assert.That(resource, Is.Not.Null, + "A legacy asset must have a matching V2 registry resource."); + + byte[] expected = JsonSerializer.SerializeToUtf8Bytes( + td, ThingDescriptionJsonContext.Default.ThingDescription); + Assert.That(resource!.DefaultVersion!.Content.ToArray(), Is.EqualTo(expected), + "The mirrored registry document must not diverge from the legacy document."); + + ServiceResult delete = await harness.Registry + .DeleteAssetAsync(assetId, CancellationToken.None).ConfigureAwait(false); + Assert.That(ServiceResult.IsGood(delete), Is.True); + Assert.That( + registry.Current.FindResource( + Opc.Ua.WotCon.Server.Registry.WotRegistryGroups.ThingDescriptions, "asset-001"), + Is.Null, "Deleting a legacy asset must remove its registry resource."); + } + [Test] public async Task RebuildSimpleWriteValueDelegatesToProviderForWritableProperties() { @@ -535,7 +578,7 @@ public async Task RebuildMaterialisesActionMethodWithExpectedArguments() Assert.That(method.BrowseName.Name, Is.EqualTo("Echo")); Assert.That(method.Executable, Is.True); Assert.That(tag.InputArguments, Has.Count.EqualTo(1)); - Assert.That(tag.InputArguments[0].DataType, Is.EqualTo(DataTypeIds.Int64)); + Assert.That(tag.InputArguments[0].DataType, Is.EqualTo(Ua.DataTypeIds.Int64)); Assert.That(tag.OutputArguments, Has.Count.EqualTo(1)); Assert.That(method.InputArguments, Is.Not.Null); Assert.That(method.OutputArguments, Is.Not.Null); diff --git a/tests/Opc.Ua.WotCon.Tests/WotConnectivityOptionsAndFactoryTests.cs b/tests/Opc.Ua.WotCon.Tests/WotConnectivityOptionsAndFactoryTests.cs index 39ebc2e3b5..b21f87a29b 100644 --- a/tests/Opc.Ua.WotCon.Tests/WotConnectivityOptionsAndFactoryTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/WotConnectivityOptionsAndFactoryTests.cs @@ -64,7 +64,7 @@ public void DefaultConfigurationParameterIsStringWritableWithoutInitialValue() { var param = new WotConfigurationParameter(); - Assert.That(param.DataType, Is.EqualTo(DataTypeIds.String)); + Assert.That(param.DataType, Is.EqualTo(Ua.DataTypeIds.String)); Assert.That(param.InitialValue, Is.Null); Assert.That(param.Writable, Is.True); Assert.That(param.Description, Is.Null); diff --git a/tests/Opc.Ua.WotCon.Tests/WotPropertyMapperTests.cs b/tests/Opc.Ua.WotCon.Tests/WotPropertyMapperTests.cs index 2582a626e3..4ed197cf0b 100644 --- a/tests/Opc.Ua.WotCon.Tests/WotPropertyMapperTests.cs +++ b/tests/Opc.Ua.WotCon.Tests/WotPropertyMapperTests.cs @@ -44,7 +44,7 @@ public void MapNumberReturnsDoubleScalar() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(valueRank, Is.EqualTo(ValueRanks.Scalar)); } @@ -56,7 +56,7 @@ public void MapIntegerReturnsInt64Scalar() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Int64)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Int64)); Assert.That(valueRank, Is.EqualTo(ValueRanks.Scalar)); } @@ -68,7 +68,7 @@ public void MapBooleanReturnsBooleanScalar() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out _); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Boolean)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Boolean)); } [Test] @@ -79,7 +79,7 @@ public void MapStringReturnsStringScalar() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out _); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.String)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.String)); } [Test] @@ -116,7 +116,7 @@ public void MapArrayOfNumbersReturnsOneDimensionalDouble() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(valueRank, Is.EqualTo(ValueRanks.OneDimension)); } @@ -132,7 +132,7 @@ public void MapArrayOfBooleansReturnsOneDimensionalBoolean() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Boolean)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Boolean)); Assert.That(valueRank, Is.EqualTo(ValueRanks.OneDimension)); } @@ -144,7 +144,7 @@ public void MapArrayWithoutItemsReturnsBaseDataType() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.BaseDataType)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.BaseDataType)); Assert.That(valueRank, Is.EqualTo(ValueRanks.OneDimension)); } @@ -166,7 +166,7 @@ public void MapArrayMatchesCaseInsensitively(string typeLiteral) bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(valueRank, Is.EqualTo(ValueRanks.OneDimension)); } @@ -182,7 +182,7 @@ public void MapPrimitiveNumberIsCaseInsensitive( bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Double)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Double)); Assert.That(valueRank, Is.EqualTo(ValueRanks.Scalar)); } @@ -195,7 +195,7 @@ public void MapPrimitiveBooleanIsCaseInsensitive( bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out _); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Boolean)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Boolean)); } [Test] @@ -207,7 +207,7 @@ public void MapPrimitiveIntegerIsCaseInsensitive( bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out _); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.Int64)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.Int64)); } [Test] @@ -219,7 +219,7 @@ public void MapPrimitiveStringIsCaseInsensitive( bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out _); Assert.That(ok, Is.True); - Assert.That(dataType, Is.EqualTo(DataTypeIds.String)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.String)); } /// @@ -233,7 +233,7 @@ public void MapUnknownPrimitiveTypeReturnsBaseDataTypeAndTrue() bool ok = WotPropertyMapper.TryMap(property, out NodeId dataType, out int valueRank); Assert.That(ok, Is.True, "Unknown primitive types still map (to BaseDataType)."); - Assert.That(dataType, Is.EqualTo(DataTypeIds.BaseDataType)); + Assert.That(dataType, Is.EqualTo(Ua.DataTypeIds.BaseDataType)); Assert.That(valueRank, Is.EqualTo(ValueRanks.Scalar)); } diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs index 27e341dcb6..c79d374b44 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateGenerator.cs @@ -3480,8 +3480,12 @@ private static string GetDescriptionValue(NodeDesign node) { if (node.Description != null && !node.Description.IsAutogenerated) { + // Cast to the NodeState base so the assignment always targets + // the node's Description attribute, even for models whose type + // declares a child Property named "Description" (a member that + // shadows global::Opc.Ua.NodeState.Description). return CoreUtils.Format( - "state.Description = {0};", + "((global::Opc.Ua.NodeState)state).Description = {0};", node.Description.GetLocalizedTextAsCode()); } return null; @@ -3768,7 +3772,11 @@ private record class ReferenceToGenerate( "Handle", "Specification", "Update", - "Delete" + "Delete", + // Method children whose generated accessor property shadows the + // identically-named global::Opc.Ua.NodeState.Validate(ISystemContext) + // instance method and therefore must be declared "public new". + "Validate" ]; private static readonly string[] s_builtInMethodNames = diff --git a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs index c68353c02d..5e66da8e78 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Generators/NodeStateTemplates.cs @@ -2240,7 +2240,7 @@ public static readonly {{Tokens.StateClassName}}Activator Instance /// public static readonly TemplateString Description = TemplateString.Parse( $$""" - state.Description = new global::Opc.Ua.LocalizedText({{Tokens.DescriptionValue}}); + ((global::Opc.Ua.NodeState)state).Description = new global::Opc.Ua.LocalizedText({{Tokens.DescriptionValue}}); """); diff --git a/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs b/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs index 5325ac7d74..4fb7e0a548 100644 --- a/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs +++ b/tools/Opc.Ua.SourceGeneration.Core/Schema/NodeSetToModelDesign.cs @@ -422,6 +422,23 @@ private XmlQualifiedName ImportSymbolicName(UANode input) return new XmlQualifiedName(input.SymbolicName, browseName.Namespace); } + // A placeholder browse name "" (no explicit SymbolicName in the + // NodeSet) follows the ModelCompiler convention of mapping to + // "Name_Placeholder" so generated identifiers match those produced + // from the equivalent ModelDesign source (e.g. a combined NodeSet + // that incorporates a model authored as ModelDesign). The raw browse + // name is used because ImportQualifiedName rewrites '<' and '>' to '_'. + string rawName = QualifiedName.Parse(input.BrowseName).Name; + if (rawName != null && + rawName.Length > 2 && + rawName[0] == '<' && + rawName[rawName.Length - 1] == '>') + { + return new XmlQualifiedName( + ToSymbolicName(rawName.Substring(1, rawName.Length - 2)) + "_Placeholder", + browseName.Namespace); + } + return new XmlQualifiedName(ToSymbolicName(browseName.Name), browseName.Namespace); } @@ -1069,6 +1086,48 @@ private void UpdateMethodDesign(UAMethod input, MethodDesign output) } } } + + DisambiguateArgumentFieldNames(output); + } + + /// + /// A method may declare an output argument whose name matches one of its + /// input arguments (for example an xRegistry method that accepts a + /// desired VersionId and returns the effective VersionId). + /// The generated typed handler emits the input arguments and the + /// by-reference output arguments as parameters/locals in the same scope, + /// so a shared name produces a duplicate C# identifier. Suffix the + /// generated field name of the colliding output arguments; the runtime + /// argument names (read positionally from the NodeSet value) are + /// unaffected. + /// + private static void DisambiguateArgumentFieldNames(MethodDesign output) + { + if (output.InputArguments == null || + output.InputArguments.Length == 0 || + output.OutputArguments == null || + output.OutputArguments.Length == 0) + { + return; + } + + var inputFieldNames = new HashSet(StringComparer.Ordinal); + foreach (Parameter input in output.InputArguments) + { + if (!string.IsNullOrEmpty(input?.Name)) + { + inputFieldNames.Add(input.GetChildFieldName()); + } + } + + foreach (Parameter arg in output.OutputArguments) + { + if (!string.IsNullOrEmpty(arg?.Name) && + inputFieldNames.Contains(arg.GetChildFieldName())) + { + arg.Name += "Out"; + } + } } private void LinkChildToParent(UAInstance input) @@ -1916,6 +1975,24 @@ private void CollectMethodDefinitions( string targetNamespace, Dictionary methods) { + // Index the explicit method nodes already present in the model by + // their symbolic name. A concrete method with arguments normally + // gets a synthesized "MethodType" declaration, but a combined + // NodeSet may already ship that method-type node explicitly (e.g. + // the incorporated WoT Connectivity 1.02 CreateAssetMethodType). + // Reuse the existing declaration in that case so code generation + // does not emit two identifiers with the same name. + Dictionary existingByName = []; + foreach (NodeDesign node in m_settings.NodesById.Values) + { + if (node is MethodDesign existing && + existing.SymbolicName != null && + !existingByName.ContainsKey(existing.SymbolicName)) + { + existingByName.Add(existing.SymbolicName, existing); + } + } + foreach (NodeDesign node in m_settings.NodesById.Values) { if (node is MethodDesign method) @@ -1924,6 +2001,26 @@ private void CollectMethodDefinitions( { continue; } + + // Skip methods whose BrowseName belongs to a base namespace + // (e.g. the Core FileType Open/Close/Read/Write methods that a + // FileType instance re-declares): they are instances of a + // base-type method and must reuse that base method type rather + // than get a synthesized method type in this model. + if (method.SymbolicName != null && + method.SymbolicName.Namespace != targetNamespace) + { + continue; + } + + // Skip standalone method-type declarations (no owning parent): + // they already act as the method type, so synthesizing a + // "MethodType" for them would create a spurious node. + if (method.Parent == null) + { + continue; + } + if (method.HasArguments && method.MethodDeclarationNode == null) { var name = new XmlQualifiedName( @@ -1935,6 +2032,24 @@ private void CollectMethodDefinitions( continue; } + // Prefer an explicit method-type declaration already in + // the model over synthesizing a colliding duplicate. The + // concrete method carries the authoritative argument + // definitions, so copy them onto the reused declaration + // to guarantee code generation emits the correct method + // signature and result even when the incorporated + // NodeSet declares the method-type argument nodes apart + // from the concrete method. + if (existingByName.TryGetValue(name, out MethodDesign declared) && + !ReferenceEquals(declared, method)) + { + declared.InputArguments = method.InputArguments; + declared.OutputArguments = method.OutputArguments; + declared.HasArguments = method.HasArguments; + method.MethodDeclarationNode = declared; + continue; + } + var declaration = new MethodDesign { SymbolicId = name, @@ -1962,11 +2077,20 @@ private void CollectMethodDefinitions( /// private XmlDecoder CreateDecoder(System.Xml.XmlElement source, string sourceNodeSetUri = null) { - var messageContext = ServiceMessageContext.CreateEmpty(m_telemetry); - messageContext.NamespaceUris = m_settings.NamespaceUris; - messageContext.ServerUris = m_serverUris; + // Reuse a single message context whose factory knows the standard + // OPC UA encodeable types. Without them, structured NodeSet2 values + // such as method Argument lists (InputArguments/OutputArguments) + // cannot be decoded and the generated typed method state would lose + // its arguments and result fields. + if (m_decoderContext == null) + { + m_decoderContext = ServiceMessageContext.CreateEmpty(m_telemetry); + m_decoderContext.NamespaceUris = m_settings.NamespaceUris; + m_decoderContext.ServerUris = m_serverUris; + m_decoderContext.Factory.Builder.AddEncodeableTypes(typeof(Argument).Assembly).Commit(); + } - var decoder = new XmlDecoder((XmlElement)source, messageContext); + var decoder = new XmlDecoder((XmlElement)source, m_decoderContext); var namespaceUris = new NamespaceTable(); @@ -2437,5 +2561,6 @@ private static string ToSymbolicName(string name) private readonly Dictionary m_aliases = []; private readonly Dictionary m_index; private readonly Dictionary m_symbolicIds; + private ServiceMessageContext m_decoderContext; } } diff --git a/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md b/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md index b4baa48aec..964e35afdd 100644 --- a/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md +++ b/tools/Opc.Ua.SourceGeneration/AnalyzerReleases.Unshipped.md @@ -13,3 +13,8 @@ MODELGEN012 | ModelSourceGenerator | Info | Multiple referenced assemblies expos MODELGEN013 | ModelSourceGenerator | Info | Model already provided by referenced assembly MODELGEN020 | ModelSourceGenerator | Warning | BrowseName requires C# string-literal escaping (UASG_BROWSENAME_UNSAFE) MODELGEN021 | ModelSourceGenerator | Error | [DataType] namespace could not be resolved +MODELGEN030 | ModelSourceGenerator | Error | WoT model could not be parsed +MODELGEN031 | ModelSourceGenerator | Error | WoT model could not be converted to a NodeSet2 model +MODELGEN032 | ModelSourceGenerator | Warning | WoT model conversion produced a warning +MODELGEN033 | ModelSourceGenerator | Info | WoT model conversion note +MODELGEN034 | ModelSourceGenerator | Error | WoT model virtual NodeSet2 path collides with another input diff --git a/tools/Opc.Ua.SourceGeneration/Extensions.cs b/tools/Opc.Ua.SourceGeneration/Extensions.cs index 14783222e5..df30408e37 100644 --- a/tools/Opc.Ua.SourceGeneration/Extensions.cs +++ b/tools/Opc.Ua.SourceGeneration/Extensions.cs @@ -36,7 +36,7 @@ namespace Opc.Ua.SourceGeneration { - internal static class Extensions + internal static partial class Extensions { /// /// Get options from file options @@ -83,6 +83,57 @@ public static bool IsDesignOrNodeset2File(this AdditionalText text) return text.HasFileExtension("xml"); } + /// + /// The canonical, unconditionally recognized WoT Thing Model / Thing + /// Description file extensions. + /// + internal static readonly string[] WotFileExtensions = + [ + ".tm.json", + ".td.json", + ".tm.jsonld", + ".td.jsonld" + ]; + + /// + /// Per-file AdditionalFiles metadata name (without the + /// generator prefix) that opts a plain .jsonld file into WoT + /// model processing. Set + /// <ModelSourceGeneratorWot>true</ModelSourceGeneratorWot> + /// on the AdditionalFiles item. + /// + private const string WotOptInPropertyName = "Wot"; + + /// + /// WoT Thing Model and Thing Description files supported as model + /// inputs. The canonical .tm.json, .td.json, + /// .tm.jsonld and .td.jsonld extensions are always + /// recognized. A plain .jsonld file is treated as arbitrary + /// JSON-LD — not consumed as a model input — unless the + /// AdditionalFiles item explicitly opts in with + /// ModelSourceGeneratorWot=true metadata. + /// + public static bool IsWotFile(this AdditionalText text, AnalyzerConfigOptions options) + { + string path = text.Path; + foreach (string extension in WotFileExtensions) + { + if (path.EndsWith(extension, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + if (!path.EndsWith(".jsonld", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + // A bare .jsonld (i.e. none of the canonical suffixes above matched) + // is only a WoT input when explicitly opted in per-file: arbitrary + // JSON-LD documents must not be silently consumed as model input. + return options != null && + options.GetBool(WotOptInPropertyName, buildProperty: false); + } + /// /// Identifer files are csv files /// diff --git a/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs index 7b6c9a6e4e..073c193c0d 100644 --- a/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/ModelSourceGenerator.cs @@ -29,6 +29,7 @@ using System.Collections.Immutable; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; using IIncrementalGenerator = SGF.IncrementalGenerator; using IncrementalGeneratorAttribute = SGF.IncrementalGeneratorAttribute; using IncrementalGeneratorInitializationContext = SGF.SgfInitializationContext; @@ -53,14 +54,58 @@ public override void OnInitialize(IncrementalGeneratorInitializationContext cont #if DEBUGX AttachDebugger(); #endif - IncrementalValueProvider> inputFiles = + // Pair every AdditionalFile with its own per-file analyzer config + // options once, up front, so both the design/NodeSet2 filter and + // the WoT filter (which needs the per-file + // ModelSourceGeneratorWot opt-in metadata to recognize a plain + // .jsonld input) can be evaluated without recomputing options. + IncrementalValuesProvider<(AdditionalText Text, AnalyzerConfigOptions Options)> textsWithOptions = context.AdditionalTextsProvider - .Where(f => f.IsDesignOrNodeset2File()) .Combine(context.AnalyzerConfigOptionsProvider) - .Select((pair, _) => ( - pair.Left, - pair.Right.GetOptions(pair.Left).ToNodeSetOptions())) + .Select(static (pair, _) => (pair.Left, pair.Right.GetOptions(pair.Left))); + + IncrementalValueProvider> xmlInputFiles = + textsWithOptions + .Where(static pair => pair.Text.IsDesignOrNodeset2File()) + .Select(static (pair, _) => (pair.Text, pair.Options.ToNodeSetOptions())) + .Collect(); + + // Every WoT input is converted independently (and cheaply cached + // per file): parse, bounds, missing preservation/native mapping, + // dependency/resolver and conversion problems are captured as + // diagnostics on the outcome rather than thrown, so one malformed + // input can never abort the whole generator run. + IncrementalValueProvider> wotOutcomes = + textsWithOptions + .Where(static pair => pair.Text.IsWotFile(pair.Options)) + .Select(static (pair, ct) => WotNodeSetAdditionalText.Convert( + pair.Text, pair.Options.ToNodeSetOptions(), ct)) .Collect(); + + // Resolve WoT outcomes against the explicit NodeSet2/ModelDesign + // inputs and each other: forwards every conversion diagnostic and + // drops (with a diagnostic) any WoT input whose synthesized + // virtual NodeSet2 path collides with another input, so a + // collision can never silently overwrite another model. + IncrementalValueProvider<( + ImmutableArray<(AdditionalText Text, NodesetFileOptions Options)> Accepted, + ImmutableArray Diagnostics)> resolvedWotInputs = + xmlInputFiles + .Combine(wotOutcomes) + .Select(static (pair, _) => pair.Left.ResolveWotInputs(pair.Right)); + + context.RegisterSourceOutput(resolvedWotInputs, static (spc, resolved) => + { + foreach (Diagnostic diagnostic in resolved.Diagnostics) + { + spc.ReportDiagnostic(diagnostic); + } + }); + + IncrementalValueProvider> inputFiles = + xmlInputFiles + .Combine(resolvedWotInputs) + .Select(static (pair, _) => pair.Left.AddRange(pair.Right.Accepted)); IncrementalValueProvider> identiferFile = context.AdditionalTextsProvider .Where(f => f.IsIdentifierFile()) diff --git a/tools/Opc.Ua.SourceGeneration/NugetREADME.md b/tools/Opc.Ua.SourceGeneration/NugetREADME.md index c2ab0103c0..4935065f28 100644 --- a/tools/Opc.Ua.SourceGeneration/NugetREADME.md +++ b/tools/Opc.Ua.SourceGeneration/NugetREADME.md @@ -28,6 +28,13 @@ Reference the generator as an **analyzer** (no runtime dependency): ``` +`AdditionalFiles` inputs may also be `NodeSet2`/`ModelDesign` XML, or a WoT +Thing Model/Thing Description (`.tm.json`, `.td.json`, `.tm.jsonld`, +`.td.jsonld`, or an opted-in plain `.jsonld`) which is converted to a NodeSet2 +document entirely in memory before generation. See +[`readme.md`](readme.md#generate-code-from-wot-thing-models--thing-descriptions) +for details, per-file options and diagnostics. + ## Target frameworks `netstandard2.0` (Roslyn analyzer host TFM). diff --git a/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props b/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props index 21e641a1c6..1548177e99 100644 --- a/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props +++ b/tools/Opc.Ua.SourceGeneration/OPCFoundation.Opc.Ua.SourceGeneration.props @@ -21,5 +21,6 @@ + \ No newline at end of file diff --git a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs index 82403d1955..ebf878799f 100644 --- a/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs +++ b/tools/Opc.Ua.SourceGeneration/SourceGenerator.cs @@ -160,6 +160,93 @@ internal static class SourceGenerator helpLinkUri: "www.opcfoundation.org", customTags: ["opcua"]); + /// + /// A WoT (.tm.json/.td.json/.tm.jsonld/.td.jsonld, + /// or opted-in .jsonld) AdditionalFile could not even be parsed as + /// JSON, could not be read, or exceeded a configured resource bound before + /// any structured conversion diagnostics were available. The input is + /// skipped; other inputs continue to be processed. + /// + public static readonly DiagnosticDescriptor WotParseError = new( + id: "MODELGEN030", + title: "WoT model could not be parsed", + messageFormat: (LocalizableString)"WoT model '{0}' could not be parsed: {1}", + category: Name, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + + /// + /// The WoT-to-NodeSet2 converter reported an error-severity + /// WotDiagnostic (for example a resource bound, a missing + /// preservation envelope/native mapping, a dependency/resolver + /// failure, or another conversion error). The stable + /// Opc.Ua.Wot.WotDiagnosticCode is embedded in the message. + /// The affected WoT input is excluded from generation; other inputs + /// continue to be processed. + /// + public static readonly DiagnosticDescriptor WotConversionError = new( + id: "MODELGEN031", + title: "WoT model could not be converted to a NodeSet2 model", + messageFormat: (LocalizableString)"WoT model '{0}': {1}", + category: Name, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + + /// + /// The WoT-to-NodeSet2 converter reported a warning-severity + /// WotDiagnostic (for example an unresolved dependency/resolver + /// reference or a lossy synthesis). Generation continues using the + /// best-effort result. + /// + public static readonly DiagnosticDescriptor WotConversionWarning = new( + id: "MODELGEN032", + title: "WoT model conversion produced a warning", + messageFormat: (LocalizableString)"WoT model '{0}': {1}", + category: Name, + DiagnosticSeverity.Warning, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + + /// + /// The WoT-to-NodeSet2 converter reported an info-severity + /// WotDiagnostic (for example a deterministically generated + /// NodeId). Provided for visibility only. + /// + public static readonly DiagnosticDescriptor WotConversionInfo = new( + id: "MODELGEN033", + title: "WoT model conversion note", + messageFormat: (LocalizableString)"WoT model '{0}': {1}", + category: Name, + DiagnosticSeverity.Info, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + + /// + /// The in-memory NodeSet2 virtual path synthesized for a WoT + /// AdditionalFile collides with an explicitly supplied + /// .NodeSet2.xml input or with the virtual path synthesized for + /// another WoT input (for example Foo.tm.json and + /// Foo.td.json both mapping to Foo.NodeSet2.xml). The + /// colliding WoT input is excluded from generation. + /// + public static readonly DiagnosticDescriptor WotVirtualPathCollision = new( + id: "MODELGEN034", + title: "WoT model virtual NodeSet2 path collides with another input", + messageFormat: (LocalizableString)("The in-memory NodeSet2 path '{1}' generated for " + + "WoT model '{0}' collides with input '{2}'; rename one of the inputs or set " + + "distinct ModelSourceGeneratorName/ModelSourceGeneratorPrefix metadata"), + category: Name, + DiagnosticSeverity.Error, + isEnabledByDefault: true, + helpLinkUri: "www.opcfoundation.org", + customTags: ["opcua"]); + /// /// Get diagnostic descriptor for event id /// diff --git a/tools/Opc.Ua.SourceGeneration/WotInputResolution.cs b/tools/Opc.Ua.SourceGeneration/WotInputResolution.cs new file mode 100644 index 0000000000..08edcecb3c --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/WotInputResolution.cs @@ -0,0 +1,102 @@ +/* ======================================================================== + * 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.Collections.Immutable; +using Microsoft.CodeAnalysis; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// WoT-specific input-resolution helpers. Kept in a dedicated partial so + /// the shared Extensions members (compiled into both the model and + /// the stack source generators) stay free of the + /// / converter dependency: only the model + /// generator (which globs this file) needs WoT NodeSet conversion, so the + /// stack generator does not compile it. + /// + internal static partial class Extensions + { + /// + /// Resolves the per-file WoT conversion outcomes against the explicit + /// NodeSet2/ModelDesign inputs and each other: every diagnostic + /// produced while converting a WoT input is forwarded, and any WoT + /// input whose synthesized in-memory NodeSet2 virtual path collides + /// with an explicitly supplied input or with another WoT input's + /// virtual path is dropped and reported via + /// so a + /// collision can never silently overwrite another model or crash the + /// underlying virtual file system. Resolution is a pure function of + /// its inputs and only compares paths, so it stays cheap and + /// deterministic across incremental re-runs. + /// + public static ( + ImmutableArray<(AdditionalText Text, NodesetFileOptions Options)> Accepted, + ImmutableArray Diagnostics) ResolveWotInputs( + this ImmutableArray<(AdditionalText Text, NodesetFileOptions Options)> xmlInputFiles, + ImmutableArray wotOutcomes) + { + var diagnostics = ImmutableArray.CreateBuilder(); + var accepted = ImmutableArray.CreateBuilder<(AdditionalText, NodesetFileOptions)>(); + // Explicit inputs are never displaced by a WoT-synthesized path; + // claim their own virtual path (== their real path) first. + var claimedBy = new Dictionary(StringComparer.Ordinal); + foreach ((AdditionalText text, NodesetFileOptions _) in xmlInputFiles) + { + claimedBy[text.Path] = text.Path; + } + + foreach (WotConversionOutcome outcome in wotOutcomes) + { + diagnostics.AddRange(outcome.Diagnostics); + if (outcome.NodeSetText is null) + { + // Parse/bounds/conversion failure already reported above. + continue; + } + string virtualPath = outcome.NodeSetText.Path; + if (claimedBy.TryGetValue(virtualPath, out string owner)) + { + diagnostics.Add(Diagnostic.Create( + SourceGenerator.WotVirtualPathCollision, + WotNodeSetAdditionalText.CreateFileLocation(outcome.SourcePath), + outcome.SourcePath, + virtualPath, + owner)); + continue; + } + claimedBy[virtualPath] = outcome.SourcePath; + accepted.Add((outcome.NodeSetText, outcome.Options)); + } + + return (accepted.ToImmutable(), diagnostics.ToImmutable()); + } + } +} diff --git a/tools/Opc.Ua.SourceGeneration/WotNodeSetAdditionalText.cs b/tools/Opc.Ua.SourceGeneration/WotNodeSetAdditionalText.cs new file mode 100644 index 0000000000..ba518c4651 --- /dev/null +++ b/tools/Opc.Ua.SourceGeneration/WotNodeSetAdditionalText.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.Collections.Immutable; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Threading; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Text; +using Opc.Ua.Export; +using Opc.Ua.Wot; + +namespace Opc.Ua.SourceGeneration +{ + /// + /// The result of attempting to convert one WoT AdditionalFile input into + /// an in-memory NodeSet2 additional text. Conversion never throws: parse, + /// bounds, missing preservation/native mapping, dependency/resolver and + /// general conversion problems are all captured as s + /// instead, so a single malformed or unsupported WoT input can never fail + /// the whole generator run. + /// + internal sealed class WotConversionOutcome + { + /// + /// The path of the original WoT AdditionalFile. + /// + public string SourcePath { get; } + + /// + /// The synthesized in-memory NodeSet2 additional text, or + /// null when conversion did not produce a usable result. + /// + public AdditionalText NodeSetText { get; } + + /// + /// The AdditionalFiles options (Prefix, Name, ModelUri, Version, + /// Ignore) captured from the original WoT input, to be preserved + /// after wrapping it as a NodeSet2 file. + /// + public NodesetFileOptions Options { get; } + + /// + /// The diagnostics produced while attempting the conversion, in the + /// order they occurred. Does not include virtual-path collision + /// diagnostics, which require the full set of inputs to detect. + /// + public ImmutableArray Diagnostics { get; } + + public WotConversionOutcome( + string sourcePath, + AdditionalText nodeSetText, + NodesetFileOptions options, + ImmutableArray diagnostics) + { + SourcePath = sourcePath; + NodeSetText = nodeSetText; + Options = options; + Diagnostics = diagnostics; + } + } + + /// + /// Presents a WoT model input to the existing generator as an in-memory + /// NodeSet2 file, using the completed converter + /// entirely in memory (no file or network I/O beyond the supplied + /// content). + /// + internal sealed class WotNodeSetAdditionalText : AdditionalText + { + private WotNodeSetAdditionalText(string path, SourceText text) + { + Path = path; + m_text = text; + } + + /// + public override string Path { get; } + + /// + public override SourceText GetText(CancellationToken cancellationToken = default) + { + return m_text; + } + + /// + /// Attempts to convert a WoT AdditionalFile into an in-memory + /// NodeSet2 additional text. Never throws (other than on + /// cancellation): every failure mode is reported as a diagnostic on + /// the returned instead, so a + /// malformed or unsupported document can never crash the generator. + /// + /// The original WoT AdditionalFile. + /// The AdditionalFiles options to preserve. + /// A cancellation token. + public static WotConversionOutcome Convert( + AdditionalText source, + NodesetFileOptions options, + CancellationToken cancellationToken) + { + string sourcePath = source.Path; + var diagnostics = ImmutableArray.CreateBuilder(); + + SourceText sourceText; + try + { + sourceText = source.GetText(cancellationToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + diagnostics.Add(CreateParseDiagnostic(sourcePath, null, ex.Message)); + return Failed(sourcePath, options, diagnostics); + } + if (sourceText is null) + { + diagnostics.Add(CreateParseDiagnostic( + sourcePath, null, "The WoT model source text could not be read.")); + return Failed(sourcePath, options, diagnostics); + } + + byte[] utf8Json; + try + { + utf8Json = Encoding.UTF8.GetBytes(sourceText.ToString()); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex.Message)); + return Failed(sourcePath, options, diagnostics); + } + + WotDocument document; + try + { + document = WotDocument.Parse(utf8Json); + } + catch (JsonException ex) + { + diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex)); + return Failed(sourcePath, options, diagnostics); + } + catch (FormatException ex) + { + // Thrown when the document exceeds a configured size bound. + diagnostics.Add(CreateParseDiagnostic(sourcePath, sourceText, ex.Message)); + return Failed(sourcePath, options, diagnostics); + } + + UANodeSet nodeSet; + try + { + using (document) + { + // No external resolver: source generation performs no + // file or network I/O beyond the supplied AdditionalText + // content, so referenced TD/TM documents are left + // unresolved (reported as a WotDiagnosticCode. + // UnresolvedReference warning by the converter). + WotConversionResult result = WotNodeSetConverter.ToNodeSetResult( + document, + options: null, + thingResolver: null, + resolutionContext: null); + AppendConversionDiagnostics(diagnostics, sourcePath, result.Diagnostics); + // Exclude any result that produced an error diagnostic even + // when a (partial or inconsistent) NodeSet value was still + // produced. This decision is independent of whether the + // MODELGEN031 error diagnostic is later reported or suppressed, + // so a suppressed conversion error can never emit a model. + nodeSet = result.Success ? result.Value : null; + } + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + // Defence in depth: the converter is documented to report + // diagnostics rather than throw. Guard against any residual + // exception so a single malformed input can never take down + // the whole generator run. + diagnostics.Add(CreateConversionExceptionDiagnostic(sourcePath, ex)); + return Failed(sourcePath, options, diagnostics); + } + + if (nodeSet is null) + { + // An error diagnostic explaining why was already appended. + return Failed(sourcePath, options, diagnostics); + } + + string xml; + try + { + using var stream = new MemoryStream(); + nodeSet.Write(stream); + xml = Encoding.UTF8.GetString(stream.ToArray()); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + diagnostics.Add(CreateConversionExceptionDiagnostic(sourcePath, ex)); + return Failed(sourcePath, options, diagnostics); + } + if (xml.Length > 0 && xml[0] == '\uFEFF') + { + xml = xml.Substring(1); + } + + var nodeSetText = new WotNodeSetAdditionalText( + GetNodeSetPath(sourcePath), + SourceText.From(xml, Encoding.UTF8)); + return new WotConversionOutcome( + sourcePath, nodeSetText, options, diagnostics.ToImmutable()); + } + + private static WotConversionOutcome Failed( + string sourcePath, + NodesetFileOptions options, + ImmutableArray.Builder diagnostics) + { + return new WotConversionOutcome(sourcePath, null, options, diagnostics.ToImmutable()); + } + + private static void AppendConversionDiagnostics( + ImmutableArray.Builder builder, + string sourcePath, + IReadOnlyList wotDiagnostics) + { + if (wotDiagnostics.Count == 0) + { + return; + } + Location location = CreateFileLocation(sourcePath); + for (int ii = 0; ii < wotDiagnostics.Count; ii++) + { + WotDiagnostic diagnostic = wotDiagnostics[ii]; + DiagnosticDescriptor descriptor = diagnostic.Severity switch + { + WotDiagnosticSeverity.Error => SourceGenerator.WotConversionError, + WotDiagnosticSeverity.Warning => SourceGenerator.WotConversionWarning, + _ => SourceGenerator.WotConversionInfo + }; + builder.Add(Diagnostic.Create(descriptor, location, sourcePath, diagnostic.ToString())); + } + } + + private static Diagnostic CreateParseDiagnostic( + string sourcePath, SourceText sourceText, JsonException ex) + { + Location location = CreateLocation(sourcePath, sourceText, ex.LineNumber, ex.BytePositionInLine); + return Diagnostic.Create(SourceGenerator.WotParseError, location, sourcePath, ex.Message); + } + + private static Diagnostic CreateParseDiagnostic( + string sourcePath, SourceText sourceText, string message) + { + Location location = CreateLocation(sourcePath, sourceText, null, null); + return Diagnostic.Create(SourceGenerator.WotParseError, location, sourcePath, message); + } + + private static Diagnostic CreateConversionExceptionDiagnostic(string sourcePath, Exception ex) + { + Location location = CreateFileLocation(sourcePath); + return Diagnostic.Create( + SourceGenerator.WotConversionError, + location, + sourcePath, + $"{ex.GetType().Name}: {ex.Message}"); + } + + /// + /// Creates a location anchored at the start of the given file. Used + /// when no more precise position is available. + /// + internal static Location CreateFileLocation(string path) + { + return Location.Create(path, default, default); + } + + private static Location CreateLocation( + string path, + SourceText text, + long? lineNumber, + long? bytePositionInLine) + { + if (text is null || text.Lines.Count == 0) + { + return CreateFileLocation(path); + } + int line = 0; + int character = 0; + if (lineNumber.HasValue && lineNumber.Value >= 0 && lineNumber.Value < text.Lines.Count) + { + line = (int)lineNumber.Value; + if (bytePositionInLine.HasValue && bytePositionInLine.Value >= 0) + { + character = Math.Min((int)bytePositionInLine.Value, text.Lines[line].Span.Length); + } + } + var position = new LinePosition(line, character); + int offset = text.Lines[line].Start + character; + var span = new TextSpan(offset, 0); + return Location.Create(path, span, new LinePositionSpan(position, position)); + } + + private static string GetNodeSetPath(string sourcePath) + { + string directory = System.IO.Path.GetDirectoryName(sourcePath) ?? string.Empty; + string name = System.IO.Path.GetFileName(sourcePath); + // The canonical suffixes are checked before the bare ".jsonld" + // fallback used by opted-in plain JSON-LD inputs: none of the + // canonical suffixes is itself a suffix of another, but every one + // of them ends with ".jsonld" or ".json" as plain text, so the + // untyped fallback must come last. + foreach (string suffix in Extensions.WotFileExtensions) + { + if (name.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - suffix.Length); + return System.IO.Path.Combine(directory, name + ".NodeSet2.xml"); + } + } + const string plainJsonLd = ".jsonld"; + if (name.EndsWith(plainJsonLd, StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - plainJsonLd.Length); + } + return System.IO.Path.Combine(directory, name + ".NodeSet2.xml"); + } + + private readonly SourceText m_text; + } +} diff --git a/tools/Opc.Ua.SourceGeneration/readme.md b/tools/Opc.Ua.SourceGeneration/readme.md index b05069f545..76af073420 100644 --- a/tools/Opc.Ua.SourceGeneration/readme.md +++ b/tools/Opc.Ua.SourceGeneration/readme.md @@ -65,6 +65,54 @@ Per-file behaviour is controlled with `AdditionalFiles` metadata: See [Source-Generated NodeManagers](../../docs/SourceGeneratedNodeManagers.md#mixing-modeldesign-and-nodeset2-in-one-project) for the end-to-end pattern. +## Generate code from WoT Thing Models / Thing Descriptions + +An `AdditionalFiles` input can also be a W3C Web of Things (WoT) Thing Model +or Thing Description. It is converted in memory to a NodeSet2 document (using +the `Opc.Ua.Wot` converter) before the normal NodeSet2/ModelDesign pipeline +above runs — the rest of the generator cannot tell the difference. No file or +network I/O beyond the supplied `AdditionalFiles` content is performed, and +externally referenced TD/TM documents are not resolved. + +The following extensions are always recognized as WoT model input: + +| Extension | Content | +| --------- | ------- | +| `.tm.json` | Thing Model, plain JSON | +| `.td.json` | Thing Description, plain JSON | +| `.tm.jsonld` | Thing Model, JSON-LD | +| `.td.jsonld` | Thing Description, JSON-LD | + +A plain `.jsonld` file is **not** treated as a WoT input by default — arbitrary +JSON-LD is not consumed as a model. Opt a specific file in with the +`ModelSourceGeneratorWot` metadata: + +```xml + + + true + + +``` + +The same per-file metadata described above (`ModelSourceGeneratorModelUri`, +`ModelSourceGeneratorName`, `ModelSourceGeneratorPrefix`, as well as +`ModelSourceGeneratorVersion` and `ModelSourceGeneratorIgnore`) is honored on a +WoT input exactly as it is on a `NodeSet2`/`ModelDesign` input, and is +preserved after the WoT document is wrapped as an in-memory NodeSet2 file. + +A malformed or unsupported WoT document never crashes the generator. Instead +it is reported through one of these diagnostics, and the affected input is +excluded from generation while every other input continues to be processed: + +| Diagnostic | Meaning | +| ---------- | ------- | +| `MODELGEN030` | The document could not even be parsed as JSON, or exceeded a configured resource bound. | +| `MODELGEN031` | The converter reported an error (a resource bound, a missing preservation envelope/native mapping, a dependency/resolver failure, or another conversion error). The `Opc.Ua.Wot.WotDiagnosticCode` is embedded in the message. | +| `MODELGEN032` | The converter reported a warning (for example an unresolved dependency reference); generation continues with the best-effort result. | +| `MODELGEN033` | The converter reported an informational note (for example a deterministically generated NodeId). | +| `MODELGEN034` | The in-memory NodeSet2 path synthesized for a WoT input (for example `Foo.tm.json` → `Foo.NodeSet2.xml`) collides with an explicitly supplied `.NodeSet2.xml` or with another WoT input's synthesized path. Rename one of the inputs, or set distinct `ModelSourceGeneratorName`/`ModelSourceGeneratorPrefix` metadata. | + ## Using DataType Generators The OPC UA source generator includes several data type generators that can be used to create classes