Fixes #4669. OPC 10000-3 §7.15 says the SourceNode of `GeneratesEvent` "shall be an ObjectType, a VariableType or a Method". CTT `Base Info Core Structure 2 001.js` flags every Object that is the source of one. #4667 fixed this in the source generator. This PR fixes the three remaining WoT places. ### 1. WotCon asset registry - `AssetRegistry.BuildEventNode` / `RemoveEventTypes` no longer add or remove `GeneratesEvent` from the asset Object. Event delivery already relies on `EventNotifier = SubscribeToEvents` and root-notifier registration. - Tests now assert the reference is **absent** and the notifier bit is set. The re-apply test asserts that the old EventType node is gone. ### 2. WoT NodeSet converter (`src/Opc.Ua.Types/Wot`) - **TD → NodeSet:** a last pass (`RelocateInstanceGeneratesEvent`) moves forward `GeneratesEvent` references off an Object/Variable root: - An event the bound type already declares needs nothing more. Matching is by qualified BrowseName against the local context's declarations, the same rule as the declaration merge. - Any other event is declared by a synthesized non-abstract `<rootLocal>Type`. It derives from the bound type (or from `BaseObjectType` / `BaseDataVariableType` when the TD binds none) and becomes the instance's `HasTypeDefinition`. A Variable root gets a `UAVariableType` with the instance's DataType and ValueRank. - Thing Models are unchanged: the projected type keeps its `GeneratesEvent`. - **NodeSet → TD:** - An instance's event affordances are now collected from its type definition and that type's supertypes. Instance-level `GeneratesEvent` is still read for third-party NodeSets. - In a single readable document, a type that only carries one instance's events (only `HasSubtype` + `GeneratesEvent`, typing exactly one instance) is not chosen as root. It is folded back: its supertype becomes the type binding, and no link is written when that supertype is the default base type. The TD → NodeSet → TD round trip stays readable, with no `uav:nodes` fallback. - A document set gives the carrier its own Thing Model, and the instance TD binds to it. - **Archive check:** an event affordance is accepted when the root's type definition, or one of its supertypes, generates it. - `docs/WoTNodeSetConversion.md` documents the behaviour in a new "Event source" row. ### 3. Aggregation sample - `SamplePump.NodeSet2.xml`: each pump is typed by its own `PumpType` subtype, `ns=1;s=Pump1.Type` (`Pump_1Type`) and `Pump2.Type`, which declares its alarm EventTypes. - It is per pump because each pump owns its alarm EventTypes. `Pump2.Type` is cloned with the rest of Pump2. - The pumps keep `EventNotifier="1"` and no longer carry `GeneratesEvent`. - The checked-in documents are regenerated, adding `sample-pump-pump-1type.json` / `-2type.json`. The pump TDs still list the alarm event affordances. - The sample binding lookup ignores Thing Models, because the type's TM declares the same EventType as the pump TD. A declaration missing from the instance still fails as before. - Docs: `WoTConnectivity.md` (asset event affordances, §12.4.2) and `WotBindings.md`. The `GeneratesEvent` clause in the sample-limitation note was already stale, so I removed it. ### Runtime check `WotNativeOwnershipIndex` only uses `GeneratesEvent` in its alias table. Projected event routing takes the owner from `uav:componentOf` / `uav:id` and the notifier from Parent/`HasNotifier`. Nothing in `Opc.Ua.WotCon.Server` reads an instance-level `GeneratesEvent`. ### Tests (net10.0) - `Opc.Ua.Types.Tests`: 11446 passed, 1 skipped. New `WotInstanceEventSourceTests` covers object and variable roots, TMs, the TD with no events, the folded round trip, the legacy instance reference and the document set. Two bound-type cases were added to `WotDeclarationMergeBoundaryTests`. - `Opc.Ua.WotCon.Bindings.Tests`: 714 passed. - `Opc.Ua.WotCon.Tests`: 1496 passed, including the byte-exact sample regeneration tests. - net48 build of both test projects is clean, and `dotnet format whitespace` is clean. Not run: CTT `Base Info Core Structure 2 001.js` (p02-base-info) against a WotCon server. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
108 KiB
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 model remains transport-neutral while the base Bindings package can bundle the dependency-compatible executors on modern .NET.
This document starts with the bindings that ship today and how to register them, then describes the contributor workflow for adding your own binding.
Table of contents
- Bindings that ship today
- Package and assembly layout
- Stable public interfaces
- Polling, retry and backoff
- Runtime integration
- OPC UA target-mapping binding runtime
- Projected Methods, events, and Conditions
- Registering binders and executors
- Intentionally unsupported operations
- Transport security
- Operation coverage (OPC UA executor)
- Event field selection (
tm:refanduav:eventSelectClauses) - Constraining an
autoendpoint selection (uav:minimumSecurity)
- Adding your own binding
- Architecture and lifecycle
- Identification and capability
- Form extraction and vocabulary terms
- Authoring OPC 10101 target mapping
- Planner validation and compiled forms
- Executors, channels, and disposal
- Payload codecs
- Credentials and trust
- Endpoint policy and custom schemes
- Registration
- Monitoring and local sampling
- Structured target mapping
- Status and error mapping
- Memory-binding implementation
- Memory-binding tests
- NativeAOT and trimming
- Packaging and TFM decisions
- Contributor checklist
- Testing matrix
- Related documentation
- Conformance to WoT Binding 1.1
Bindings that ship today
Package and assembly layout
| Project, assembly, or namespace | Contents | Availability and dependencies |
|---|---|---|
src/Opc.Ua.WotCon.Bindings / Opc.Ua.WotCon.Bindings |
Stable interfaces, plan model, codecs, the eight planner/validator binders, and registry. No sample binding ships in this library. | Base package OPCFoundation.NetStandard.Opc.Ua.WotCon.Bindings; full net48;net8.0;net9.0;net10.0 matrix. |
Opc.Ua.WotCon.Bindings.Http |
HTTP executor and options, included in the base Bindings package | net8.0, net9.0, and net10.0; HttpClient. |
Opc.Ua.WotCon.Bindings.Modbus |
Modbus TCP client, executor, addressing, and conversion, included in the base Bindings package | net8.0, net9.0, and net10.0; sockets only. |
Opc.Ua.WotCon.Bindings.OpcUa |
OPC UA-to-OPC UA executor and options, included in the base Bindings package | net8.0, net9.0, and net10.0; Opc.Ua.Client. |
src/Opc.Ua.WotCon.Bindings.Mqtt / Opc.Ua.WotCon.Bindings.Mqtt |
MQTT executor and options | Separate OPCFoundation.NetStandard.Opc.Ua.WotCon.Bindings.Mqtt package for net8.0, net9.0, and net10.0; MQTTnet. |
Opc.Ua.WotCon.Server |
Materialization coordinator integration | References Opc.Ua.WotCon.Bindings only. |
samples/WotCon |
Runnable sample guide plus AggregationClient, AggregationServer, and FlatTagServer projects. AggregationServer/Bindings/MemoryWotBinding.cs is a reference custom binding only. |
Sample applications; the memory binding is deliberately not registered in the sample host. |
The base Bindings package keeps its full TFM matrix, but its concrete HTTP, Modbus, and OPC UA executor namespaces are compiled only for net8.0, net9.0, and net10.0. MQTT remains separate because it carries an optional external transport dependency. Planner-only use therefore remains available on every base-package TFM. The WoT samples now live under samples/WotCon; their project, assembly, and namespace names are AggregationClient, AggregationServer, and FlatTagServer without a Wot prefix. The sample guide is samples/WotCon/README.md.
The plural Bindings name is part of every current artifact and namespace. Do not add new references to the retired singular Opc.Ua.WotCon.Binding* names.
Stable public interfaces
All contracts live in the Opc.Ua.WotCon.Bindings namespace.
- Identification, version and capability
WotBindingIdentity— a binder's stableId+Version(id@versionkey). 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 generatedWoTBindingCapabilityDataType.IWotBindingIdentification— deterministic selection. A binder returns aWotBindingMatch(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 resolvedopdefaults, security scheme references and JSON Pointers).IWotBindingPlanner— validates a form and compiles it into aWotBindingCompilationof immutableWotCompiledFormentries carryingWotEndpointDescriptor/WotAddressingDescriptor/WotOperationDescriptor/WotPayloadDescriptor/WotTargetMappingDescriptormetadata.
- Target mapping (OPC 10101 §6.5.4, with the protocol-neutral example in §8.2)
WotTargetMappingDescriptor— the protocol-neutraluav:mapToNodeId/uav:mapToType/uav:mapByFieldPathterms authored on a property affordance (never on a form), letting a non-OPC-UA source (Modbus, HTTP, …) be projected onto an OPC UA target NodeId or a field of a structured target type.WotAffordanceForm.TargetMappingparses it from the owning affordance;WotProtocolBinderRegistry.Preparevalidates it once for every protocol (property-only,mapByFieldPathrequiresmapToType, non-empty values, never authored on a form) and attaches it to everyWotCompiledFormit produces, so individual planners never parse or duplicate it.
- 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 fromsecurityDefinitions.IWotCredentialProvider— resolves a reference into short-livedWotCredentialmaterial at runtime, out-of-band. No secret ever appears in a Thing Description or on a registry node.
- Lifecycle and operations
IWotBindingExecutor—ActivateAsyncopens a per-formIWotBindingChannel.IWotBindingChannel—ReadAsync/WriteAsync/InvokeAsync/ObserveAsync/SubscribeEventAsync, returningWotReadResult/WotWriteResult/WotInvokeResultwith mappedStatusCodes.
- Registry and structured diagnostics
IWotBinderRegistry/WotProtocolBinderRegistry— the Prepare / Activate / Deactivate seam the coordinator uses.WotBindingDiagnostic— severity + stable code + RFC 6901 JSON Pointer.
Polling, retry and backoff
A transport with no native push channel (HTTP, Modbus) implements ObserveAsync with the shared PollingWotSubscription, so a poll-only driver does not write its own timer loop. The poll callback reports health: it returns false when the source failed without throwing, which is how a binding that maps a failure onto a bad StatusCode reports it. Both the mapped bad status and a thrown fault are surfaced as a notification, so a variable never silently keeps its last good value while the asset is down.
Consecutive unhealthy polls back off through an IChannelReconnectPolicy — the same abstraction the stack already uses for channel reconnects — so an offline device is not hammered once per poll cycle. The default is ExponentialBackoffChannelReconnectPolicy (500 ms doubling to 30 s, unlimited attempts); set RetryPolicy on HttpWotBindingOptions / ModbusWotBindingOptions to change it. Backing off never polls faster than the configured interval, the first healthy poll resets it, and a policy that reports "stop retrying" ends the loop rather than spinning.
The interval itself comes from the form where the protocol binding defines a standard term for it. Modbus does: modv:pollingTime (milliseconds, per the W3C Modbus binding — distinct from modv:timeout, which is a request timeout) is compiled onto WotOperationDescriptor.PollInterval and wins over the executor's configured ObserveInterval. HTTP has no standard polling term, so it uses HttpWotBindingOptions.ObserveInterval. No vendor-specific uav: term is introduced for this.
For Modbus TCP, ModbusTcpClient treats a faulted socket as disposable state. The next read or write transaction reconnects before sending the MBAP request, so polling backoff controls retry rate while ordinary operations can recover without recreating the binding channel.
Eight planner/validator binders ship in Opc.Ua.WotCon.Bindings (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 (Opc.Ua.WotCon.Bindings.Http, bundled on net8+) |
| CoAP | w3c.coap |
W3C Binding Templates CoAP | Editor's Draft | planner only |
| MQTT | w3c.mqtt |
W3C Binding Templates MQTT | Editor's Draft | yes (Opc.Ua.WotCon.Bindings.Mqtt, separate package) |
| Modbus TCP | w3c.modbus |
W3C Binding Templates Modbus | Editor's Draft | yes (Opc.Ua.WotCon.Bindings.Modbus, bundled on net8+) |
| 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 for WoT Binding) | OPC specification | yes (Opc.Ua.WotCon.Bindings.OpcUa, bundled on net8+) |
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
opcompatibility,contentTypeand 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
BadConfigurationErrorand emits aWoTBindingFailureEvent. Validated-but-non-executable forms also degrade the closure so their nodes are visible but flagged. - Binding capability snapshots populate the registry
SelectedBindingsnode and contribute to refresh unchanged-detection. - The legacy 1.02
IWotAssetProviderFactoryprovider model is preserved untouched. - The coordinator passes its prepared
WotBindingPlans to the host asWotProjectionDocument.BindingPlans(anArrayOf<WotBindingPlan>), so the projection host can wire a per-generation OPC UA binding runtime once the closure's NodeSet2 content has been imported.
OPC UA target-mapping binding runtime
Once a closure's forms are materialized as NodeSet2 content, LifecycleWotProjectionHost wires each runtime NodeSet generation's RuntimeNodeSetOptions.ConfigureAsync to build a projection binding runtime from the document's BindingPlans. This implemented generic runtime drives live target-mapped value exchange between the resolved OPC UA variable and the compiled forms of its non-OPC-UA or OPC UA source; it is not limited to a protocol-specific projection.
IWotBindingChannelFactory(implemented byWotProtocolBinderRegistryalongsideIWotBinderRegistry) opens a liveIWotBindingChannelfor a compiled, executable form. Dependency injection registers the sameWotProtocolBinderRegistrysingleton for both interfaces regardless of whetherAddWotRegistryServerorAddWotProtocolBinders/Add<Protocol>WotBindingis called first.IWotTargetVariableResolver(defaultWotTargetVariableResolver) resolves the targetBaseVariableStateaWotTargetMappingDescriptordeclares against the freshly imported predefined nodes:uav:mapToNodeIdalone resolves that exact portable NodeId (parsed includingnsu=forms againstINodeManagerBuilder.Context.NamespaceUris) and requires aBaseVariableState.uav:mapToTypealone resolves the unique variable whoseDataTypeequals the target type.- Both terms resolve the exact node and validate its
DataTypeequals the declared target type. - Missing, malformed, ambiguous, wrong-node-class or type-mismatch mappings fail activation with a deterministic
ServiceResultExceptionstatus (BadNodeIdInvalid/BadNodeIdUnknown/BadBrowseNameDuplicated/BadTypeMismatch); every portable NodeId parse failure — including one the parser itself raises as aServiceResultException— is wrapped asBadNodeIdInvalidnaming the offending term (uav:mapToNodeId/uav:mapToType) rather than surfacing the parser's own exception shape.
IWotProjectionBindingRuntimeFactory(defaultWotProjectionBindingRuntimeFactory) groups the closure's target-mapped, executable compiled forms by resolved target variable and returns aWotProjectionBindingRuntime— theIAsyncDisposablethe NodeSet generation owns:- A direct target (
uav:mapToNodeIdand/oruav:mapToTypealone) wires the executablereadproperty/writepropertyforms as full asyncOnRead/OnWritehandlers that preserve the sourceStatusCodeandSourceTimestamp; local monitored items sample the same read handler, so no second observe bridge is created for anobservepropertyform on the same target. - A structured target (
uav:mapToType+uav:mapByFieldPath) composes the value by reading every mapped field concurrently, building nested structures viaIEncodeableFactory/IStructure/IDataTypeDefinitionSource(no reflection); writes extract and write each mapped field concurrently from the incoming structure. A single failing field fails the whole read or write; a successful read preserves a non-defaultGoodstatus if any field reported one and uses the oldest non-MinValueSourceTimestampacross the fields, rather than always reporting plainGood/now. - Conflicting direct-vs-field mappings, duplicate read/write mappings for the same target/field, and unsupported target operations all fail activation deterministically. Everything else about a structured target that depends on its structure type being registered — the encodeable type lookup, root instance validation, and
uav:mapByFieldPathpath resolution (empty segments, unknown fields, array-valued or non-structure intermediate fields) — is deferred to the first structured read or write instead of failing activation, becauseRuntimeNodeSetOptions.ConfigureAsyncruns beforeNodeManagerLifecycle.RefreshComplexTypesAsyncregisters the server's custom structure types. Resolution is retried, uncached, on every first use until it succeeds against the (by-then-populated)IEncodeableFactoryinstance; a still-unresolved first use returns a deterministicBadConfigurationErrorread/write status instead of throwing out of the request pipeline. - Channels are opened lazily and cached one-per-compiled-form for the generation; concurrent first use opens once, and a failed open is evicted so a later call can retry. Every successfully opened channel is disposed with the generation; disposal failures are aggregated. A channel open racing with, or started after, generation disposal never leaks: disposal marks the slot disposed under its lock so no later open can start, and still awaits and disposes a channel whose open was already in flight.
- A direct target (
- Both abstractions are always available via direct construction (no DI container required) and are registered through
AddWotRegistryServerusingTryAdd*so a host application can supply its own implementation.
Projected Methods, events, and Conditions
WotBindingPlan.ProjectedAffordances carries each declaration's local identity,
owning resource, and JSON Pointer separately from its upstream form address.
An action's uav:id identifies the local Method; its selected form supplies the
upstream Method and Object addresses. The runtime wires the local Method through
the existing asynchronous fluent OnCall hook. When an action offers several
forms, the runtime selects one supported, executable form and sends the request
only to that form's upstream source. It returns the upstream call's status and
any argument errors to the caller; invoking the local Method does not by itself
count as success.
Type-owned declarations need not have executable forms. Their declaration
context follows document containment or authoritative native ownership, not
HasTypeDefinition: a real instance of a Thing Model still needs executable
bindings for the actions and events it exposes. Native partitions remain
authoritative; routing metadata cannot introduce missing local Nodes.
A property backed by a native Variable needs no transport when it has no form and no explicit target mapping. Its local Value remains exactly as the NodeSet declares it, including an unspecified initial Value. This covers companion-model metadata as well as local constants; it does not invent a data source for an unbacked property or excuse a target mapping without a form.
IWotProjectionEventPublisher registers generation-owned streams of events with the
existing monitored-source lifecycle. Compatible local consumers share an
upstream subscription; the last consumer releases that subscription, while the
generation retains its channel until disposal. Events are reported through the local
notifier hierarchy. Organizational projection Views do not become event sources.
The stream implements IEventSourceReadiness, so creating an event monitored
item waits until its upstream subscriptions are active, not until the first
notification arrives. A startup failure is returned to the subscriber.
An event declaration identifies an EventType, not mutable Condition state.
IWotProjectionConditionFactory creates separate local Condition instances.
Selected fields are translated through their message-context namespace tables,
and a bounded occurrence-route table maps local EventIds back to the selected
source, Condition, and branch. Wrong-source, unknown, or evicted IDs fail rather
than falling back to another source. Retired-generation routes remain usable
only while that generation is alive and the declaration and source still match.
Condition-management actions use uav:conditionAction and same-document uav:actsOn.
For a WoT invocation with an optional Comment, the OPC UA adapter supplies
LocalizedText.Null when the caller provides only EventId. This does not change
a native two-argument Method's signature: OPC UA callers supply both arguments.
After acknowledgement changes the occurrence, confirmation uses the updated
EventId. Unbound standard Methods on a Condition proxy are disabled, so they
cannot change only the local copy.
WotProjectionBindingRuntimeOptions limits pending notifications per local
notifier (MaxQueuedEvents) and retained occurrence routes per event declaration
(MaxEventRoutes). If the notification queue fills, the producer finishes
delivering already queued events, then stops with a BadTooManyOperations
error and releases its upstream subscription leases. The monitored-source
lifecycle logs the failure; other event sources continue running. This is a
server-side producer failure, not an EventQueueOverflowEventType notification
or termination of the client's entire subscription. Evicting an occurrence route
has a different result: a later action using that EventId fails with
BadEventIdUnknown. The event publisher, Condition factory, runtime factory,
and options are injectable as well as available for direct construction. The
two-pump aggregation sample demonstrates
source-specific management actions and acknowledgement/confirmation without
introducing shelving, suppression, dialog, or ConditionRefresh transport mappings.
Registering binders and executors
The planner binders are opt-in and replaceable. AddHttpWotBinding, AddModbusWotBinding, and AddOpcUaWotBinding come from the base Bindings package on net8.0+; AddMqttWotBinding requires the separate MQTT package:
builder
.AddWotRegistryServer(o => o.StrictBindings = false)
.AddHttpWotBinding() // planners + HTTP executor
.AddModbusWotBinding() // + Modbus TCP executor
.AddMqttWotBinding() // + MQTT executor
.AddOpcUaWotBinding(o => o.SessionFactory = ConnectSessionAsync);
Each Add<Protocol>WotBinding 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:
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.
To write MyCustomBinder and MyCustomExecutor see Adding your own binding. The worked
AggregationServer.MemoryWotBinder implementation in the WotCon aggregation sample binds a fictitious mem:// protocol
to an in-process key/value store.
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/MonitoredItempair per channel, Part 4 §5.12 / §5.13) — see 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; anmqtt://href stays explicit plaintext (port 1883). Username / password, the TLS client certificate and TLS trust anchors are resolved through theIWotCredentialProvider; a form that declares a security scheme is refused when the provider resolves no credential. Username / password over plaintextmqtt://is refused unlessMqttWotBindingOptions.AllowCredentialsOverPlaintextis set. - HTTP — the executor-owned
HttpClientdisables 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, anhttps→httpdowngrade is refused unlessAllowInsecureRedirectDowngradeis set, and the hop count is capped byMaxAutomaticRedirects(default 5). A caller-supplied client used with a credential-bearing form fails closed unlessHttpWotBindingOptions.CallerClientHandlesRedirectSafetyconfirms the client handles redirects without leaking credentials. - Modbus —
modv:addressmust 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 toushort/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) whose EventFilter select clauses are the compiled WotEventSelection of WoT Binding Section 6.1: the eight mandatory BaseEventType fields (EventId, EventType, SourceNode, SourceName, Time, ReceiveTime, Message, Severity) when the affordance states no selection, and otherwise the selection resolved from the EventType definition it links to with tm:ref, overlaid by the uav:eventSelectClauses it states. Every selected field is delivered in WotNotification.EventFields, keyed by its browse path — an empty path supplies ConditionId — 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.
Event field selection (tm:ref and uav:eventSelectClauses)
WoT Binding Section 6.1 states an event's EventFilter select clauses on the event
affordance — never on a form — and states them by linking the EventType definition
the fields are selected from. An events map is an affordance map wherever it appears,
not only at the document root, so a clause on a member of a nested one — the event
collection a link carries, for instance — is equally legal, and a clause one level away
from any such member (at the root, on a property affordance, on an action's input, on
an event's data, on a form) selects nothing and is rejected. The permission and the
prohibition route at the same places on purpose: a rule that admitted a nested map while
forbidding a clause inside it would make one document simultaneously valid and invalid.
The link is a tm:ref, and it names the definition in one
of three shapes: a document URI optionally followed by an RFC 6901 JSON Pointer, the
logical identifier of a document whose root is an EventType Thing Model, or the logical
identifier a nested event affordance carries in @id. A logical identifier is a JSON-LD
term, so a compact IRI such as evt:highTemperatureAlarm is expanded in the active
context of the node that wrote it — the same short form written in two documents that
bind the prefix differently names two different definitions. It resolves to an EventType
definition — an event affordance, or a Thing Model root, that carries
@type: uav:eventType, the portable uav:id of the OPC UA EventType, and the
object-valued data schema of its fields. uav:id alone does not make an affordance a
definition: it identifies the Node the affordance projects, which every event affordance
has. uav:eventSelectClauses is the refinement of that baseline: each clause
carries exactly tm:ref and uav:browsePath (relative, because the definition the
clause names anchors it).
"events": { "highTemperature": {
"@type": "uav:eventType",
"tm:ref": "./event-types.tm.jsonld#/events/highTemperatureAlarm",
"uav:eventSelectClauses": [
{ "tm:ref": "./event-types.tm.jsonld#/events/limitAlarm",
"uav:browsePath": "HighHighLimit" },
{ "tm:ref": "./event-types.tm.jsonld#/events/highTemperatureAlarm",
"uav:browsePath": "Severity" }
],
"forms": [{ "href": "opc.tcp://server:4840", "uav:id": "i=2253",
"op": ["subscribeevent"] }] } }
What the runtime does with it:
- Resolution happens before planning.
Opc.Ua.Wot.WotEventSelectionResolverresolves eachtm:refthrough anIWotThingResolver— the sibling documents a caller already holds — and never dereferences a URI over the network. Sources are consulted in a fixed order: the documents held together with the referring one (matched by logical identifier), then that reference as a location through each configured resolver in the order it was given, then the small well-known catalog this library carries for the OPC UA base types. The order is total and each stage yields a set rather than a first match, so a reference two held documents answer differently is reported as ambiguous rather than resolved by whichever was read first, and the built-in catalog is last so a definition this library carries can never shadow one an author shipped. That catalog'sBaseEventTypedeclaresLocalTimein addition to the eight mandatory fields, because a definition states what a type has while the implicit selection states what a consumer subscribes to when the document says nothing. It walks the linked definition'sdataonce and turns each leaf into one clause: the members of an object are walked in the order itsuav:fieldOrderstates, a member'suav:browseNamesupplies the exact QualifiedName (a bare member name stands for it only where that name is a legal unqualified BrowseName), theConditionIdmember yields the empty path, and a state Variable's trailingNameis dropped because the clause naming the Variable supplies that object'sName. Every derived clause carries the definition'suav:idas itsTypeDefinitionId. Derivation is total: a definition the resolver cannot walk — adatathat is not an object, a walked object with no field order, a member name that is neither legal nor annotated — is reported, and no partial selection is produced. - The explicit clauses overlay a linked baseline, and replace a missing one. Where the
affordance carries a
tm:ref, the materialized member paths are computed over the derived baseline and the explicit clauses together, every baseline clause an explicit clause names is removed, and the explicit clauses are appended in the order they are written. There is no remove operation: an author who needs a narrower selection links to a definition that declares the narrower field set. Where the affordance carries notm:ref, the clauses it writes are the complete selection — the baseline is empty. The eight mandatoryBaseEventTypefields (EventId,EventType,SourceNode,SourceName,Time,ReceiveTime,Message,Severity), stated once inOpc.Ua.Wot.WotEventSelectClauses.Default, are what an affordance that states no selection at all falls back to; they are not a floor under an authored one, because a document that deliberately selects one field must not subscribe to nine. - Planning stays synchronous.
IWotBinderRegistry.Prepareis side-effect free, so the resolved selections are carried into it: build the request withWotBindingPlanRequest.FromDocumentAsync(..., IWotThingResolver, ...), or resolve once withWotBindingPlanRequest.ResolveEventSelectionsAsyncand pass the resultingWotEventSelectionCatalogtoWotBindingPlanRequest.FromDocument. Inside a server the materialization coordinator does this for every closure member, using the same snapshot resolver the conversion uses. An affordance that states a selection and reaches a planner with no resolved selection fails the form withEventSelectClauseInvalidrather than performing I/O during planning. OpcUaBindingPlannercompiles the effective selection ontoWotCompiledForm.EventSelectionas an ordered list ofOpc.Ua.Wot.WotResolvedEventSelectClause, each carrying the portableTypeDefinitionIdits definition declared.- A compact path element such as
pump:Temperatureis rewritten to the portablensu=<NamespaceUri>;Temperatureform using the prefixes the document's@contextbinds (WotBindingPlanContext.NamespacePrefixes). An unbound prefix fails the form withUnboundNamespacePrefixrather than guessing a namespace. - A browse path is parsed into elements once —
PathElements, produced byWotEventSelectClauses.SplitBrowsePath— and every rule below is stated over those elements rather than over the joined string. A NamespaceUri routinely contains/, which is also the path separator, so only the separators that follow the delimiter ending a NamespaceUri (;for the OPC 10000-6nsu=form,}for the OPC 10000-4{...}form) separate elements.nsu=http://example.org/pump/;Temperatureis therefore one element whose member isdata.Temperature, and not five elements nesting the field undernsu=http:, an empty member andexample.org. Escaping does not solve this — OPC 10000-6 §5.3.1.11 escapes only;and%— so the elements, and never the joined string, are what the member path, the field name, the collision check, theSimpleAttributeOperandbrowse path and the nesteddataobject are all built from.WotEventSelectClauses.JoinBrowsePathis the exact inverse. OpcUaWotBindingChannelmaterializes each clause into aSimpleAttributeOperandagainst the connected session's namespace table, using the resolved portableTypeDefinitionId. The empty browse path selects theNodeIdAttribute — the OPC 10000-9ConditionIdidiom — and every other clause selectsValue.- Two clauses shall not materialize the same
datamember, even where they reference different EventTypes and even where their normalized browse paths differ: the materialized member path — the sequence ofdatamember names the clause fills — is what decides the output, so two clauses that reach it would compete for it and nothing in the document would say which of them filled it. Normalization resolves each element's prefix to the NamespaceUri the document binds it to, so two prefixes for one namespace name one path; but the member name drops the qualification altogether and a state Variable appendsName, so an unqualifiedSeveritybeside a namespace-qualifiedSeverity, andEnabledStatebesideEnabledState/Name, are each two paths and one member. A collision is anEventSelectClauseInvaliderror inWotEventSelectClauses.TryParse, in the resolver's overlay, and again in the planner, which re-checks the list it rewrote into portable form. - An
EventFilterWhereClause/ContentFilteris out of scope of the Binding; a clause carrying one is rejected withEventSelectClauseInvalidinstead of being reinterpreted. The same holds for the NodeId clause form: a clause names its EventType by reference, souav:typeDefinitionIdis rejected as an unexpected member.
What a notification carries: the nested data object and the transport index
A clause materializes into exactly one member of the event affordance's data
object, by a rule that is a function of its browse path and the list the clause
sits in:
| Clause path | data member |
|---|---|
"" (empty) |
data.ConditionId |
Severity |
data.Severity |
EnabledState/Id |
data.EnabledState.Id |
EnabledState |
data.EnabledState.Name |
A data member name therefore never contains the path separator:
EnabledState/Id is two nested members and not one member called
EnabledState/Id. Where the selected Node is an OPC UA state Variable — whose
own value is the state's localized display text and whose Id sub-Variable
carries the Boolean — the clause naming the field supplies that object's Name
member. Opc.Ua.Wot.WotEventSelectClauses.StateVariableFieldNames names the
states this Binding declares (EnabledState, AckedState, ConfirmedState,
ActiveState), which is exactly the set the Condition data schema of
Section 13.3 writes as an { Id, Name } object; a companion state is recognized
from the selection itself, because a field another clause of the same list nests
through is an object whose Name member carries the field's own value.
WotNotification carries both representations, built together from one
selection so they cannot disagree:
WotNotification.Datais the nestedWotEventDataobject above — the shape the Binding describes, and the one to read.WotNotification.EventFieldsis the flat index keyed by the joined browse path the document authored (EnabledState/Id, andConditionIdfor the empty path). Section 6.1 names this what it is: a transport-side artifact of one implementation, because aMonitoredItemreturns field values positionally and a runtime naturally keys them by the clause that asked for them. It is kept for compatibility; a document never names adatamember with a joined browse path.
Where two clauses would fill one data member the plan is rejected before a
subscription exists, so the runtime never has to choose. If a collision still
reaches WotEventDataBuilder — a plan assembled around the planner, or a Server
field list the selection does not describe — the first stated clause keeps the
member and the collision is logged as an error rather than silently dropped.
The superseded uav:eventFields spelling this implementation minted before the term was
standardized is still read — it is authored on a form, carries bare browse names and
adds to the default selection — and is never written. Where a form carries both,
the standardized term wins and the contradiction is reported (ConflictingFields)
rather than silently merged. New documents should author the standardized terms
instead: link the EventType definition with tm:ref and state only the clauses that
refine it, as described above.
Constraining an auto endpoint selection (uav:minimumSecurity)
WoT Binding Section 5.7.1 lets an auto security scheme state a floor:
"securityDefinitions": {
"opcua_auto_sc": {
"scheme": "auto",
"uav:minimumSecurity": {
"uav:securityMode": "Sign",
"uav:securityPolicy": "Basic256Sha256"
}
}
}
The planner compiles it onto WotCompiledForm.SecurityFloor. A floor the Binding cannot
read — one carried by a scheme other than auto, or naming a mode or policy Section 5.7
does not — fails the form (InvalidSecurityFloor) instead of compiling without the
constraint.
Because endpoint selection needs an application configuration, a certificate store and a transport this library is deliberately not given, the choice is made by a delegate — but the rules are made here, and the executor never opens a session it could not have chosen:
OpcUaWotBindingOptions.ConstrainedSessionFactoryreceives anOpcUaWotSessionRequestcarrying the floor, so a caller's own factory can discard endpoints before opening a channel.OpcUaWotBindingOptions.EndpointDiscoverytogether withSelectedEndpointSessionFactoryis the built-in path: the executor calls discovery, appliesOpcUaWotEndpointSelector.Select, and hands the chosenEndpointDescriptionto the factory. The selection is the clause's own — discard everything below the floor, then take the strongest mode, then the strongest policy (ranking a policy the Binding does not name below every policy it does), then the highestsecurityLevel, then the smallestendpointUrlin ascending Unicode code-point order (the sharedWotCodePointComparerof Annex G.3), then the earliest position in the response. Where no endpoint is eligible the activation fails withBadSecurityModeRejectedand no session is opened: a client shall fail and report rather than fall back below a stated floor.- The endpoint-blind
SessionFactorystays exactly as it was where the form states no floor. A form that states one and finds only that factory configured fails withBadConfigurationErrornaming what to configure, rather than opening a session through a factory that could not honour the floor and rejecting whatever endpoint it happened to pick — a false negative that reads as "no endpoint is strong enough" even when the Server offers one. - Whichever path is used,
OpcUaWotBindingExecutorverifies the endpoint the returned session reports and fails closed (BadSecurityModeRejected, session disposed) when it is below the floor, or when the session cannot state its endpoint at all. A floor whose enforcement was merely assumed would be a claim rather than a guarantee.
The clause constrains a choice among the endpoints a Server already offers and nothing else: certificate trust, trust-list policy, filtering on any other endpoint attribute and transport-profile negotiation stay with the application's own security configuration.
Adding your own binding
This guide explains how to add a protocol binding to the WoT Connectivity runtime from form identification through live
value exchange, registration, diagnostics, tests, packaging, and NativeAOT validation. The current worked implementation
is MemoryWotBinding.cs in the WotCon aggregation
sample, so it demonstrates the extension pattern without shipping in the Opc.Ua.WotCon.Bindings package or being
registered by the sample host. A test-only copy lives in
tests/Opc.Ua.WotCon.Tests/Support/MemoryWotBinding.cs.
The production HTTP, Modbus TCP, OPC UA, and MQTT implementations provide protocol-specific examples.
Architecture and lifecycle
The binding pipeline separates pure document processing from transport I/O:
WotFormExtractorparses property, action, and event forms into immutableWotAffordanceFormvalues. It applies default WoTopvalues, inherits Thing-level security when a form has no override, clones the form and affordance JSON, and records RFC 6901 JSON Pointers.- Every
IWotProtocolBinderexposes a stableWotBindingIdentity, a version-pinnedWotBindingCapability, deterministicIWotBindingIdentification, and anIWotBindingPlanner. WotProtocolBinderRegistry.Preparevalidates protocol-neutral target mapping, selects one binder for each form, and calls its planner without performing transport I/O.- The planner validates protocol vocabulary and addressing, then emits one immutable
WotCompiledFormper supported operation. A compiled form carries endpoint, addressing, operation, payload, secret-free credential references, target mapping, and executability. - The materialization coordinator converts a dependency closure to runtime NodeSet2 content and passes its plans in
WotProjectionDocument.BindingPlans. - After the NodeSet is imported,
WotProjectionBindingRuntimeFactorywires the compiled forms to target variables. Wiring is synchronous and performs no transport I/O. - On first read or write,
WotBindingChannelSlotasksIWotBindingChannelFactory.OpenChannelAsyncfor a live channel. The registry resolves the matchingIWotBindingExecutorand creates aWotExecutorContextcontaining credentials, codecs, and bounds. - The runtime NodeSet generation owns the resulting
IAsyncDisposablebinding runtime. The runtime owns every lazily opened channel and disposes them when that generation drains and is removed.
IWotBinderRegistry.ActivateAsync is called only after the new projection becomes active. On replacement, the shadow switch succeeds before the coordinator deactivates the old plans and activates the new plans. The old runtime NodeSet generation can continue serving its existing monitored items until they drain; its generation-owned channels are not disposed until that old generation is removed. If conversion, wiring, or shadow activation fails, the previous active generation remains available.
Identification and capability
Use a stable binder id and a version that identifies the planner behavior. WotBindingIdentity.Key is id@version, and multiple versions can coexist. Executor lookup first uses the exact key and then the id-level default.
WotBindingCapability must accurately describe the version-pinned source document, operations, content types, and whether the binding has a runtime implementation. The capability is projected to WoTBindingCapabilityDataType, advertised by the registry, and included in unchanged-generation decisions.
Identification must be deterministic. WotProtocolBinderBase.MatchStandard implements the normal precedence: an explicit resource pin is stronger than a vocabulary match, which is stronger than a URI-scheme match. The registry evaluates binders in ordinal id@version order and uses that order to break equal-priority matches. Override Match directly when the protocol also requires a subprotocol or a pinned shape rule.
Do not claim a form merely because its URI scheme is vaguely related to the protocol. A false positive prevents a better binder from compiling the form and turns a protocol-selection problem into misleading planner diagnostics.
Form extraction and vocabulary terms
WotAffordanceForm.FormElement contains the form object and is where protocol-specific form vocabulary normally belongs. AffordanceElement contains the owning property, action, or event. Use TryGetString, TryGetBoolean, TryGetInt32, and TryGetStringArray instead of deserializing arbitrary objects or using reflection.
The planner should validate every term it consumes, reject contradictory terms, enforce WotBindingBounds, and report diagnostics at form.Pointer("term"). Use form.AffordancePointer("term") only for terms defined on the owning affordance. Unknown terms from a pinned vocabulary should produce UnknownVocabularyTerm when accepting them could change behavior.
WotFormExtractor emits a formless descriptor for an affordance with no forms array. This intentionally makes strict materialization reject an affordance that has no executable route instead of silently ignoring it.
Authoring OPC 10101 target mapping
OPC 10101 section 6.5.4 defines generic OPC UA vocabulary terms for annotating Thing Descriptions. Section 8.2 demonstrates that the mapping vocabulary is not limited to OPC UA source forms: its example maps properties from a Modbus energy meter into an OPC UA data model.
The runtime implements the following affordance-level semantics:
uav:mapToNodeIdidentifies the exact OPC UA target variable.uav:mapToTypeidentifies the target variable by its OPC UADataType; resolution requires a unique variable of that type.- When both are present, the exact node is resolved and its
DataTypemust equaluav:mapToType. uav:mapByFieldPathmaps a property to a field within a structured target and is valid only together withuav:mapToType.- All three terms belong on a property affordance. Authoring them inside an individual form is invalid, and authoring them on an action or event is invalid.
- Values must be non-empty strings. The registry validates these rules before any protocol planner runs and copies one
WotTargetMappingDescriptorto every compiled operation for that property.
This direct mapping is valid because the target term is a sibling of forms on the property affordance:
{
"properties": {
"temperature": {
"type": "number",
"uav:mapToNodeId": "nsu=urn:example:aggregate;s=Device1.Temperature",
"forms": [
{
"href": "https://sensor.example.test/temperature",
"op": "readproperty"
}
]
}
}
}
A structured mapping puts both type and field path on the property:
{
"properties": {
"lineVoltage": {
"type": "number",
"uav:mapToType": "nsu=urn:example:types;s=EnergyMeasurementsType",
"uav:mapByFieldPath": "VoltageL1N",
"forms": [
{
"href": "modbus+tcp://meter.example.test",
"op": "readproperty",
"modv:entity": "holdingregister",
"modv:address": 100
}
]
}
}
}
Moving any uav:mapTo* or uav:mapByFieldPath member inside the form object is invalid even if the form uses the OPC UA protocol.
Use portable nsu= NodeIds whenever documents can move between servers whose namespace indexes differ. WotTargetVariableResolver parses uav:mapToNodeId and uav:mapToType with ExpandedNodeId.Parse(text, builder.Context.NamespaceUris), so nsu=urn:vendor:model;s=Device1.Value resolves against the materialized generation's namespace table. A numeric ns= identifier is valid only when the author controls the target server's namespace-index assignment.
Target mapping is protocol-neutral. The form can address HTTP, Modbus, MQTT, OPC UA, or a custom protocol while the affordance maps the resulting value to an OPC UA variable. Protocol planners must not parse, reinterpret, or discard uav:mapToNodeId, uav:mapToType, or uav:mapByFieldPath.
Planner validation and compiled forms
Deriving from WotProtocolBinderBase provides helpers for common work:
RequireHrefvalidates presence andMaxUriLength.TryParseUri,SchemeOf,MakeEndpoint, andMakeEndpointOrSyntheticnormalize endpoint metadata.ResolveOperationsvalidates affordance/operation compatibility, filters unsupported operations, and avoids duplicate teardown entries.ResolveCodecselects a codec and createsWotPayloadDescriptor.ResolveSecurityconverts document security definitions into secret-freeWotCredentialReferencevalues.
Return WotBindingCompilation.Unsupported(...) when the binder cannot produce any valid entry. Return Supported(entries, diagnostics) only when entries are non-empty and there are no error diagnostics. The registry treats a compilation with errors as unsupported even if entries were returned.
Keep WotCompiledForm immutable and transport-neutral. Put protocol additions in the Metadata dictionaries of WotEndpointDescriptor, WotAddressingDescriptor, WotOperationDescriptor, or WotPayloadDescriptor. Do not store open clients, mutable protocol state, credentials, delegates, or disposable resources in a plan.
A planner can ship without an executor. The registry still validates and compiles its forms but marks its entries non-executable and the non-strict projection degraded. This is the preferred path for landing a validator before the transport runtime is ready.
Executors, channels, and disposal
IWotBindingExecutor.CanExecute should reject compiled forms for another identity. ActivateAsync receives one immutable compiled form and a WotExecutorContext; it returns a live IWotBindingChannel.
The channel implements read, write, invoke, property observation, event subscription, and asynchronous disposal. Unsupported operations return BadNotSupported instead of throwing. Transport failures should be translated into deterministic StatusCode results; cancellation requested by the caller should normally remain cancellation, while an executor-owned timeout should become BadTimeout.
The projection runtime opens channels lazily. One WotBindingChannelSlot is shared for each compiled-form object within a generation, concurrent first use opens exactly once, a failed open is evicted for retry, and one caller's cancellation does not cancel the generation-scoped open for other callers. Disposal marks the slot closed before awaiting an in-flight open, then disposes any successfully created channel. Channel disposal must be idempotent, and subscription disposal must stop delivery and release its transport resources.
Do not create transport connections in the planner, binder constructor, or DI registration callback unless the executor itself explicitly owns a long-lived pooled client. Prefer an injectable client/session factory in options, as the built-in executors do.
Payload codecs
The default WotPayloadCodecRegistry contains reflection-free JSON, text, and octet-stream codecs. A planner records only the codec id and payload metadata; a channel selects the codec from WotExecutorContext.Codecs when it encodes or decodes.
Custom codecs implement IWotPayloadCodec and return WotEncodeResult or WotDecodeResult rather than throwing for expected malformed input. Register custom codecs ahead of the built-ins with WotPayloadCodecRegistry.Register, or provide an IWotCodecRegistry through DI. Keep codecs deterministic, bounded, culture-invariant, and free of runtime type discovery.
Credentials and trust
Thing Descriptions and registry nodes contain only WotSecurityDefinition and WotCredentialReference data. Actual headers, query values, usernames, passwords, certificates, and trust anchors are resolved at channel activation or request time through IWotCredentialProvider.
Register a provider with AddWotCredentialProvider. Scope credentials by the reference's scheme name, binding URI, and endpoint. Fail closed when a form declares security but the provider cannot resolve the required material. Never serialize WotCredential, cache secret text in WotCompiledForm, or include secrets in diagnostics.
Endpoint policy and custom schemes
WotEndpointPolicy is an allow-list that decides which endpoint URIs an executor may reach. It fails closed: the default set covers only the schemes the shipped bindings use (http, https, modbus+tcp, modbus, mqtt, mqtts, opc.tcp, opc.https, opc.wss), and it blocks loopback, RFC1918, CGNAT, link-local (including the cloud metadata address 169.254.169.254) and IPv6 ULA ranges.
A custom binding almost always introduces a scheme the default set does not know about, so opening a channel fails with BadSecurityChecksFailed and Endpoint scheme '<scheme>' is not in the policy's AllowedSchemes set until the scheme is opted in:
var endpointPolicy = new WotEndpointPolicy();
endpointPolicy.AllowedSchemes.Add("mem");
Add only the scheme your binding needs, and leave the address-range restrictions alone unless the deployment genuinely requires them relaxed — those blocks are what stop a Thing Description from steering an executor at the host's own listeners or at a cloud metadata endpoint.
Internationalized hosts
A href may name an internationalized host (http://ü.example/x). Percent-encoding is defined for a path, a query and a fragment and is not a spelling of a host, so the transmitted URI is rebuilt from its components rather than encoded as one string: the host becomes its IDNA A-label (http://xn--tda.example/x), and userinfo, an explicit port and an IPv6 literal are carried through unchanged. WotProtocolBinderBase.ToTransmittedUri produces the URI on the wire and ToTransmittedAuthority the authority the plan is scoped to, so WotCompiledForm.Endpoint.Host, Endpoint.BaseUri, Addressing.Target and every WotCredentialReference.Endpoint name one host.
WotEndpointPolicy is evaluated against the same A-label — WotEndpointValidator.ToAsciiHost exposes it. An allow list accepts either spelling of one name; a block list refuses either, because a policy that blocks xn--tda.example while the plan carries ü.example would block nothing.
Registration
The direct-construction path is useful in focused tests. Note the policy passed alongside the binder and executor, which is what lets the sample's mem:// endpoints resolve:
var store = new MemoryWotStore();
var endpointPolicy = new WotEndpointPolicy();
endpointPolicy.AllowedSchemes.Add("mem");
var registry = new WotProtocolBinderRegistry(
[new MemoryWotBinder()],
[new MemoryWotBindingExecutor(store)],
endpointPolicy: endpointPolicy);
The normal host path uses IOpcUaBuilder extensions:
MemoryWotStore store = new();
IOpcUaBuilder opcUa = services
.AddOpcUa()
.AddServer(server => { /* server configuration */ })
.AddWotRegistryServer(options => options.StrictBindings = false);
opcUa
.AddWotBinder(new MemoryWotBinder())
.AddWotBindingExecutor(new MemoryWotBindingExecutor(store))
.AddWotCredentialProvider(NullWotCredentialProvider.Instance);
EnsureWotBinderRegistry registers one WotProtocolBinderRegistry singleton and exposes that same instance as both IWotBinderRegistry and IWotBindingChannelFactory, independent of registration order. A custom binding package should expose one fluent Add<Protocol>WotBinding method that creates options, calls AddWotProtocolBinders or AddWotBinder, and registers its executor.
Monitoring and local sampling
For a target-mapped variable, the generic projection runtime wires executable readproperty and writeproperty forms to async OnRead and OnWrite handlers. Local OPC UA monitored items sample that same read handler. An observeproperty entry does not create a second upstream observe bridge for target mapping, so a binding must provide a reliable and bounded read operation even when its native protocol also supports push observation.
Outside target mapping, callers can use IWotBindingChannel.ObserveAsync or SubscribeEventAsync directly. The returned IWotSubscription owns the native subscription or polling loop and must stop it in DisposeAsync.
Structured target mapping
Direct mapping reads or writes the whole target value. Structured mapping groups forms by target variable and field path. Reads run all mapped field reads concurrently, build nested IStructure instances without reflection, and return one ExtensionObject. Writes extract each mapped field and run the field writes concurrently.
The runtime rejects a target that mixes direct and field mappings, duplicate read mappings for the same field, duplicate write mappings for the same field, and target-mapped operations other than read, write, or observe. A failed field fails the entire structured operation. A successful structured read preserves a non-default Good status when present and uses the oldest available source timestamp.
Structure type and field-path resolution is delayed until first structured use because runtime NodeSet configuration completes before custom encodeable types are registered in the shared factory. Failed resolution is not cached; later operations retry. Until resolution succeeds, the read or write returns BadConfigurationError.
Status and error mapping
Return a WotReadResult, WotWriteResult, or WotInvokeResult for expected protocol outcomes. Reserve exceptions for invalid API use, cancellation, resource construction failures, and conditions that prevent a channel from being opened.
| Condition | Recommended status |
|---|---|
| Unsupported channel operation | BadNotSupported |
| Payload encode/decode failure | BadEncodingError / BadDecodingError |
| Executor-owned timeout | BadTimeout |
| Network or broker failure | BadCommunicationError |
| Missing protocol target | BadNodeIdUnknown or a protocol-specific mapped status |
| Invalid compiled address | BadNodeIdInvalid or BadInvalidArgument |
| Authentication or authorization rejection | BadUserAccessDenied |
| Response exceeds configured bounds | BadEncodingLimitsExceeded |
| Invalid runtime mapping or structured configuration | BadConfigurationError |
Preserve a source protocol's meaningful OPC UA status and timestamps when the source is OPC UA. Do not expose credentials or stack traces through Error; use concise operator-safe text and server-side telemetry for detailed exceptions.
Memory-binding implementation
The following excerpt is the checked-in sample implementation pattern. It supports mem:// property read, write, and polling-based observation. Use the linked source file as the authoritative copy if this excerpt is trimmed in rendered documentation.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua;
using Opc.Ua.WotCon;
using Opc.Ua.WotCon.Bindings;
namespace AggregationServer
{
/// <summary>
/// A worked sample showing how a third party contributes a replaceable
/// protocol binder as pure code-behind. The fictitious <c>mem</c> 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 <c>builder.AddWotBinder(new MemoryWotBinder())</c> and
/// <c>builder.AddWotBindingExecutor(new MemoryWotBindingExecutor(store))</c>.
/// </summary>
public sealed class MemoryWotBinder : WotProtocolBinderBase
{
/// <summary>
/// The sample binding vocabulary URI.
/// </summary>
public const string BindingUri = "urn:example:wot:mem";
private static readonly string[] s_schemes = ["mem"];
/// <inheritdoc/>
public override WotBindingIdentity Identity { get; } =
new WotBindingIdentity("example.mem", "1.0", BindingUri, "Sample In-Memory Binding");
/// <inheritdoc/>
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."),
[
WoTBindingCapabilityEnum.ReadProperty,
WoTBindingCapabilityEnum.WriteProperty,
WoTBindingCapabilityEnum.ObserveProperty
],
["application/json", "text/plain"],
isExecutable: true);
/// <inheritdoc/>
protected override IReadOnlyCollection<string> Schemes => s_schemes;
/// <inheritdoc/>
public override WotBindingMatch Match(WotAffordanceForm form, WotBindingSelectionContext context)
{
return MatchStandard(form, context, "memv:");
}
/// <inheritdoc/>
public override WotBindingCompilation Compile(WotAffordanceForm form, WotBindingPlanContext context)
{
var diagnostics = new List<WotBindingDiagnostic>();
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]);
}
string key = uri.AbsolutePath.Trim('/');
ResolveCodec(form, context, out WotPayloadDescriptor payload);
WotEndpointDescriptor endpoint = MakeEndpoint(uri);
var addressing = new WotAddressingDescriptor(key);
ImmutableArray<WotCompiledForm>.Builder entries = ImmutableArray.CreateBuilder<WotCompiledForm>();
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,
[], Capability.IsExecutable));
}
return entries.Count == 0
? WotBindingCompilation.Unsupported([.. diagnostics])
: WotBindingCompilation.Supported(entries.ToImmutable(), [.. diagnostics]);
}
}
/// <summary>
/// The in-process key/value store the sample binding reads and writes.
/// </summary>
public sealed class MemoryWotStore
{
/// <summary>
/// Gets the value stored under a key.
/// </summary>
public DataValue Get(string key)
{
return m_values.TryGetValue(key, out DataValue value) ? value : new DataValue(Variant.Null);
}
/// <summary>
/// Sets the value stored under a key.
/// </summary>
public void Set(string key, DataValue value)
{
m_values[key] = value;
}
private readonly ConcurrentDictionary<string, DataValue> m_values =
new(StringComparer.Ordinal);
}
/// <summary>
/// The executor for the sample in-memory binding.
/// </summary>
public sealed class MemoryWotBindingExecutor : IWotBindingExecutor
{
/// <summary>
/// Initializes a new sample executor over the supplied store.
/// </summary>
public MemoryWotBindingExecutor(MemoryWotStore store)
{
m_store = store ?? throw new ArgumentNullException(nameof(store));
}
/// <inheritdoc/>
public WotBindingIdentity Identity { get; } =
new WotBindingIdentity("example.mem", "1.0", MemoryWotBinder.BindingUri, "Sample In-Memory Executor");
/// <inheritdoc/>
public bool CanExecute(WotCompiledForm form)
{
return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal);
}
/// <inheritdoc/>
[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<IWotBindingChannel> 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<IWotBindingChannel>(channel);
}
private readonly MemoryWotStore m_store;
}
/// <summary>
/// The live channel for the sample in-memory binding.
/// </summary>
internal sealed class MemoryWotBindingChannel : IWotBindingChannel
{
public MemoryWotBindingChannel(MemoryWotStore store, WotCompiledForm form)
{
m_store = store;
Form = form;
m_key = form.Addressing.Target;
}
public WotCompiledForm Form { get; }
public ValueTask<WotReadResult> ReadAsync(CancellationToken cancellationToken = default)
{
return new ValueTask<WotReadResult>(new WotReadResult(StatusCodes.Good, m_store.Get(m_key)));
}
public ValueTask<WotWriteResult> WriteAsync(DataValue value, CancellationToken cancellationToken = default)
{
m_store.Set(m_key, value);
return new ValueTask<WotWriteResult>(new WotWriteResult(StatusCodes.Good));
}
public ValueTask<WotInvokeResult> InvokeAsync(
IReadOnlyList<Variant> inputs, CancellationToken cancellationToken = default)
{
return new ValueTask<WotInvokeResult>(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<IWotSubscription> ObserveAsync(
Action<WotNotification> onNotification, CancellationToken cancellationToken = default)
{
if (onNotification is null)
{
throw new ArgumentNullException(nameof(onNotification));
}
var subscription = new PollingWotSubscription(Form, token =>
{
onNotification(new WotNotification(m_store.Get(m_key)));
return new ValueTask<bool>(true);
}, TimeSpan.FromMilliseconds(200));
return new ValueTask<IWotSubscription>(subscription);
}
public ValueTask<IWotSubscription> SubscribeEventAsync(
Action<WotNotification> onEvent, CancellationToken cancellationToken = default)
{
return ObserveAsync(onEvent, cancellationToken);
}
public ValueTask DisposeAsync()
{
return default;
}
private readonly MemoryWotStore m_store;
private readonly string m_key;
}
}
Memory-binding tests
The positive test compiles a TD, selects the generated read and write entries, opens channels through the registry, and verifies round-trip behavior:
[Test]
public async Task SampleBinderCompilesAndExecutesReadWrite()
{
var store = new MemoryWotStore();
var registry = new WotProtocolBinderRegistry(
[new MemoryWotBinder()],
[new MemoryWotBindingExecutor(store)]);
const string td =
"""
{
"@context": "https://www.w3.org/2022/wot/td/v1.1",
"title": "Memory device",
"properties": {
"setpoint": {
"type": "number",
"forms": [{ "href": "mem://store/setpoint" }]
}
}
}
""";
WotBindingPlan plan = registry.Prepare(
WotBindingPlanRequest.FromDocument(
"memory-device",
WoTDocumentKindEnum.ThingDescription,
Encoding.UTF8.GetBytes(td)));
Assert.That(plan.FullySupported, Is.True);
Assert.That(plan.HasExecutableForms, Is.True);
WotCompiledForm write = plan.CompiledForms.Single(
form => form.Operation == WoTBindingCapabilityEnum.WriteProperty);
WotCompiledForm read = plan.CompiledForms.Single(
form => form.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));
}
}
Add a diagnostic test so malformed input remains actionable:
[Test]
public void SampleBinderReportsInvalidHrefAtTheFormPointer()
{
var registry = new WotProtocolBinderRegistry(
[new MemoryWotBinder()]);
const string td =
"""
{
"title": "Invalid memory device",
"properties": {
"setpoint": {
"forms": [{ "href": "mem://[invalid" }]
}
}
}
""";
WotBindingPlan plan = registry.Prepare(
WotBindingPlanRequest.FromDocument(
"invalid-memory-device",
WoTDocumentKindEnum.ThingDescription,
Encoding.UTF8.GetBytes(td)));
Assert.That(plan.FullySupported, Is.False);
Assert.That(
plan.Diagnostics.Any(d =>
d.Code == WotBindingDiagnosticCode.InvalidHref &&
d.JsonPointer == "/properties/setpoint/forms/0/href"),
Is.True);
}
The checked-in equivalent is WotCustomBinderSampleTests.cs. Protocol executor tests belong in tests/Opc.Ua.WotCon.Bindings.Tests, while planner, registry, target-mapping, and materialization tests belong in tests/Opc.Ua.WotCon.Tests.
NativeAOT and trimming
Binding code must remain compatible with trimming and NativeAOT. Parse form vocabulary with JsonElement; do not use runtime assembly scanning, unbounded reflection, Type.GetType, dynamic code generation, or serializer overloads that require runtime metadata. Use source-generated JSON contexts when a protocol needs typed JSON beyond the built-in scalar codec.
Keep plan objects data-only and immutable. Inject transport factories and credential providers instead of locating services dynamically. Ensure asynchronous cleanup does not depend on finalizers. If a dependency is not annotated as AOT-compatible, add a NativeAOT smoke path that exercises every used feature.
The base Bindings project sets IsAotCompatible for compatible net10.0 builds, and the aggregation samples publish with PublishAot on net10.0. Validate a new concrete executor with a net10.0 build and, when it participates in a sample or app, a real dotnet publish -f net10.0 -r <rid>.
Packaging and TFM decisions
Keep protocol abstractions and planners in the base Bindings project when they can compile across the full library matrix without a transport dependency. Place a concrete executor in the base project only when its dependencies are already suitable for the bundled net8.0+ build, as with HTTP, Modbus TCP, and OPC UA. Use a separate package when the executor introduces an optional external dependency, as MQTT does.
Conditionally exclude executor source on older TFMs rather than reducing the base package's TFM matrix. Public documentation and package README files must state both facts: the package is available on all library TFMs, and the concrete executor namespaces exist only on net8.0+.
Contributor checklist
- Use plural
Opc.Ua.WotCon.Bindingsproject, package, and namespace names. - Pin an authoritative binding source and version in
WotBindingSource. - Choose a stable binder id, version, binding URI, display name, and capability set.
- Implement deterministic identification and verify tie/pin behavior.
- Validate required vocabulary, conflicts, bounds, operations, content types, and security references without transport I/O.
- Emit immutable compiled forms with precise endpoint, address, operation, payload, credential, and JSON Pointer data.
- Leave OPC 10101 target mapping to the protocol-neutral registry/runtime.
- Implement an executor only for operations the transport can actually perform.
- Map expected failures to OPC UA status codes and keep caller cancellation distinct from executor timeout.
- Resolve credentials out of band and verify that diagnostics never contain secrets.
- Opt the binding's URI scheme into
WotEndpointPolicy.AllowedSchemesand leave the address-range blocks intact. - Make channels, subscriptions, and in-flight activation safe under asynchronous disposal.
- Register direct-construction and DI/fluent paths.
- Add planner, diagnostics, executor, concurrency, disposal, and security tests.
- Test local monitored-item sampling when the binding is used through target mapping.
- Test direct and structured mappings when the protocol is intended for aggregation.
- Verify all supported TFMs,
net10.0trimming/AOT behavior, package contents, and README accuracy.
Testing matrix
| Area | Required cases |
|---|---|
| Identification | Scheme match, vocabulary match, explicit pin, no match, deterministic tie, multiple binder versions. |
| Form extraction | Default operations, form operation override, Thing-level security fallback, relative/base URI behavior if supported, formless affordance. |
| Planner validation | Valid form, missing/invalid href, incompatible operation, unsupported content type, missing term, invalid term shape/range, conflicting terms, configured bounds. |
| Diagnostics | Stable code, severity, offending term, exact RFC 6901 form or affordance pointer, no secret leakage. |
| Compiled plan | Endpoint, addressing, operation, payload, security references, target mapping, executable/non-executable state. |
| Codec | Encode/decode round trip, malformed payload, empty payload, maximum payload, culture independence. |
| Credentials | No-security path, missing required credential, correct endpoint scoping, secure transport, explicit rejection of unsafe downgrade. |
| Executor | Read, write, invoke, observe, event, every intentionally unsupported operation, source status/timestamp preservation. |
| Failure mapping | Timeout, cancellation, connection loss, protocol error, encode/decode failure, authentication failure, oversized response. |
| Concurrency | Concurrent first channel use opens once, failed open retries, parallel operations obey transport rules. |
| Disposal | Never-opened channel, successfully opened channel, failed open, in-flight open racing disposal, subscription partial-construction failure, repeated disposal. |
| Target mapping | Affordance-level direct mapping, nsu= mapping, forms-level rejection, action/event rejection, field path requires type, direct/field conflict, duplicate field direction. |
| Structured mapping | Nested fields, unknown field, non-structure intermediate, array-valued intermediate, one failed field, status/timestamp aggregation. |
| Materialization | Strict rejection, non-strict degradation, successful activation, failed shadow replacement retaining old generation, old monitored-item drain. |
| Packaging | Full base TFM matrix, executor source absent before net8.0, MQTT separate package, package README and dependency graph. |
| AOT/trimming | net10.0 analyzer-clean build and NativeAOT publish/run smoke test for the concrete executor path. |
Related documentation
- WoT Connectivity model, server, registry, and client
- WoT aggregation sample - exercises the complete generic projection runtime with two OPC UA source servers, runtime-loaded DI/Machinery/Pumps models, local monitored items, and shadow-generation replacement.
- Dependency injection
- Runtime NodeSets
Conformance to WoT Binding 1.1
The specification defines twelve conformance units and four recommended profiles (Section 11). This is where the implementation stands against them.
| Unit | Status | Where |
|---|---|---|
| WoT-ProtocolBinding | covered | URI/base/href handling, the four service mappings, access levels, the security schemes and the auto endpoint-selection constraint of Section 5.7.1, in Opc.Ua.WotCon.Bindings and its planners |
| WoT-NativeMapping | covered | WotNodeSetConverter, including the proof that uav:nodes is omitted when the readable mapping is complete. It descends the whole composition tree (FromNodeSetDocuments, §9.1's "Thing / nested Thing"), seeds namespaces from @context, and keeps type definitions, DataTypes and scalar values. See What the readable mapping cannot express below |
| WoT-StructuredFallback | covered | the structured uav:nodes projection in WotNativeProjection |
| WoT-JsonResidue | covered | WotJsonResidue, pointer-addressed preservation through the NodeSet Extension |
| WoT-NodeSetPreservation | covered | the byte-exact uav:nodeSet envelope with digest verification |
| WoT-ExactRoundtrip | covered | the envelope-free roundtrip invariants, including residue |
| WoT-EventMapping | covered | subscribeevent / unsubscribeevent mapped to event MonitoredItems, including the EventType tm:ref fast path, the uav:eventSelectClauses overlay and the implicit BaseEventType default (Section 6.1) |
| WoT-ConditionMapping | covered | Section 13 (uav:conditionType, uav:conditionTypeId, uav:conditionAction, uav:actsOn) in WotNodeSetConverter.Conditions, with the Condition supertype resolution and the Section 13.3/13.4 conformance rules |
| WoT-ModelVocabulary | covered | WotNodeSetConverter.ModelVocabulary and WotNodeSetConverter.Conformance, all Section 6 terms with their validation rules |
| WoT-DataTypeDefinition | covered | WotNodeSetConverter.DataTypes, the explicit and inferred DataType definitions of Section 6.11 |
| WoT-ExternalResolver | covered | WotResolver for uav:externalSchema, uav:mapToType, uav:mapToNodeId and cross-document links |
| WoT-Projection | covered | WotProjection, WotProjectionResolver and, for materialization, WotProjectionViewBuilder with LifecycleWotViewProjectionHost |
All four profiles - WoT-Reader, WoT-Modeller, WoT-Converter and WoT-ArchivalConverter - are therefore satisfied by the units above.
The unit and profile names themselves are stated once, in
Opc.Ua.Wot.WotBindingConformance, together with the vocabulary revision this
library implements (CurrentRevision, 1.1) and the profile nesting Section 11
defines. A document declares what it claims with uav:profile and the revision it
was authored against with uav:bindingVersion (Section 4.1); both are validated,
neither becomes a Node, and both are restated verbatim on a round trip.
What the readable mapping does not yet carry
Readable mappings and their fallback conditions are maintained in WoT / NodeSet conversion. In particular, engineering units, ranges and scaling are mapped in both directions when their values can be decoded. Missing or unsupported values use the documented preservation projection rather than invented defaults.
Use this guide for protocol planners, executors, and binding registration; use WoT Connectivity for registry hosting and projection lifecycle. Keeping conversion rules in one place avoids binding implementations depending on an outdated list of mapping limitations.
Equivalence and byte identity are different contracts. NodeSetComparer.CompareEquivalent
resolves each NodeSet's aliases, while NodeSetComparer.Compare retains stricter
text comparison. See the conversion guide for readable, preservation, and exact
round-trip guarantees.
How this is checked
The specification publishes twenty-six worked examples, and two of them are a
golden pair: a projection document and the resolved view it is defined to
resolve to. WotSpecExampleTests embeds all twenty-six and runs the pair
through the resolver, asserting against the specification's own expected output
rather than against our reading of the prose. That covers, in one document, all
three selection forms, the bulk naming rule, the security closure naming and the
provenance term. Example 22 is additionally converted to check that a document
binds the node it projects to an existing type (Section 5.2.1) and constrains
its auto endpoint selection with a Section 5.7.1 floor. Three examples were
added by revision 1.1 and pin the corrections it made: a document whose texts
are authored in German and French while the default locale is en
(example 24, the code-point-first display fallback of Section 9.1.1), a Thing
Model that projects a ReferenceType Node with uav:inverseName and
uav:symmetric (example 25, Section 6.2.1), and one that projects a DataType
Node (example 26, Sections 5.2 and 6.11).
WotSpecExampleTests.EveryPublishedExamplePassesStrictConformanceAndImports
runs every one of the twenty-six through the whole reading pipeline — parse,
strict conformance validation, conversion, then serialize, re-read and
Import. A document that claims a profile covering WoT-Modeller has to
convert: the claim is what makes the conversion mandatory rather than optional.
Keeping the vendored examples honest
The examples are vendored byte-for-byte from the specification repository into
tests/Opc.Ua.Types.Tests/Wot/Assets, and .gitattributes marks *.jsonld
eol=lf so the checked-out bytes are the upstream bytes on every platform.
They used to be copied by hand with no record of the source, and they drifted:
one example gained a security floor upstream while the copy here kept the
superseded text, and a later example never arrived at all.
spec-examples.manifest.json beside them now records the source repository,
branch and commit, the vocabulary revision, and the size and SHA-256 of every
file. WotSpecFixtureManifestTests enforces it in three layers:
| Check | Needs | What it catches |
|---|---|---|
| manifest set, count, numbering and per-file SHA-256 | nothing — offline, from embedded resources | an edited, replaced, added or dropped example, and a gap in the NN- numbering that would hide a missing tail |
| manifest provenance | nothing | a manifest that names no full source commit, or that records a revision this library does not implement |
| byte identity against the specification checkout | a sibling spec-drafts checkout, or OPCUA_WOT_SPEC_DRAFTS |
a regeneration made from the wrong source, which would record wrong hashes just as consistently |
The third check is skipped, not failed, where no checkout is present, so CI needs
neither the network nor a second repository. Re-vendoring is the explicit
developer step WotSpecFixtureManifestTests.RegenerateFromSpecCheckout, which
copies the published examples over the vendored ones and rewrites the manifest
from git in the source checkout — so the diff a reviewer sees is the
specification's diff.
Resolving a type binding: the local context
Section 5.2.1 lets a document bind the node it projects to a type that already
exists rather than to BaseObjectType. Section 5.1.5 defines where that name is
looked up — the local context, which has two parts consulted in order:
- the other WoT documents being converted alongside this one, and
- a loaded AddressSpace.
The order matters. A set of documents authored together resolves to itself, so loading an unrelated companion model can never change what an existing document projects to.
IWotNodeResolver (in Opc.Ua.Types) is one part of that context.
WotCompositeNodeResolver composes parts in the specified order and is what a
converter is handed. A compact model name is a hint and may match none, one or
several nodes; an ExpandedNodeId is definitive and matches one or none.
| Implementation | Part of the context | Assembly |
|---|---|---|
SnapshotWotNodeResolver |
the sibling documents of the conversion | Opc.Ua.WotCon.Server |
AddressSpaceWotNodeResolver |
the types the Server has loaded | Opc.Ua.WotCon.Server |
NullWotNodeResolver |
holds nothing; the default | Opc.Ua.Types |
Both halves are composed with WotCompositeNodeResolver in the specified
order. The AddressSpace half is what lets a document bind to a type a companion
model defines — the primary use of §5.2.1 — and it is wired in by
WotRegistryNodeManager as soon as an IServerInternal exists. Without it a
document could only bind to a type a sibling projects, and because §5.2.1
forbids falling back to BaseObjectType a companion-model binding would fail
the projection instead of resolving.
SnapshotWotNodeResolver indexes the registry snapshot being converted. Only
Thing Models are indexed, and the decision uses the registry's Kind rather
than the document's own content: a Thing Model projects its root as a
UAObjectType and so is what a type binding can name, whereas a Thing
Description projects an instance and is never a type-binding target. Trusting
the registry Kind means a party who can only submit Thing Descriptions cannot
plant a type for another document to bind to. The identity it indexes by is
derived through WotNodeSetConverter.TryDescribeProjectedType, the same rules
the conversion itself uses, so an index entry and the projected node cannot
disagree.
The index is built once per snapshot, not once per conversion — a refresh
converts every resource of one immutable snapshot in turn, so rebuilding it per
document would make a refresh parse the registry once per document. It is also
bounded by the same MaxResolverDocuments / MaxResolverTotalBytes budget the
rest of a conversion runs under, so a large registry cannot turn one conversion
into unbounded parsing work.
The Section 5.2.1 declaration rule is implemented: IWotTypeDeclarationResolver
reports a resolved type's instance declarations, the asynchronous entry point
pre-resolves them into a WotDeclarationCatalog, and a document member whose
NamespaceUri-qualified BrowseName is exactly a declaration's populates that
declaration — adopting its ReferenceType, type definition, DataType, ValueRank,
ArrayDimensions and, for a Method, the declaration it is an instance of —
instead of becoming a second, differently-reached Node under a name the type has
already spoken for. Each populated member reports DeclarationPopulated.
A closure that is only partly known is treated as partly known rather than as empty:
- Every declaration that was read is applied. A declaration the local context answered for is a fact about the bound type, and skipping it because some other part of the closure could not be read produces exactly the duplicate sibling the clause forbids.
- The gap is always reported, as
DeclarationsUnavailable. A document statinguav:additionalProperties: falsefails — Section 6.8 is a closed-content statement and it cannot be evaluated against a closure that is not whole. An open document states no such rule, so its populated members stand and the gap is a warning; it is never silence, because silence is indistinguishable from a type that declares nothing. - A member the known part does not declare is not reported as
UndeclaredMemberwhile the closure is incomplete: whether the unread part declares it was never established.
AddressSpaceWotNodeResolver draws the same distinction at the source. A bad
BrowseResult.StatusCode, a browse or read the node manager refuses, a
BrowseName naming a namespace index the Server does not hold, and a
ModellingRule that cannot be read each mark the returned
WotTypeDeclarationSet incomplete and name the cause in Detail, rather than
contributing "declares nothing".
Two behaviours are deliberate and worth knowing:
- A binding is told apart from an ordinary
@typeannotation by namespace, not by whether the lookup succeeds. A name in a namespace the local context holds is a binding, so failing to resolve it is an error rather than a reason to quietly treat it as an annotation. - An unresolved or ambiguous binding fails the projection. It never falls
back to
BaseObjectType, because silently mistyping a node is worse than refusing to project it.
A host that supplies no resolver gets NullWotNodeResolver, which holds
nothing. A document that names no existing type still converts; one that does is
reported as unresolved rather than mistyped.
Both forms resolve through the local context, including the definitive
ua:HasTypeDefinition link: §5.2.1's outcome table fails the projection for a
link that "resolves to nothing" exactly as it does for an unresolved name.
Emitting an unverified identifier would leave a dangling HasTypeDefinition,
which is the silently mistyped node the clause exists to prevent — so the
synchronous and asynchronous entry points agree on every document, and a caller
with no local context fails such a document rather than trusting the author.
An ambiguous name and an otherwise invalid document are separate outcomes in
§5.2.1 and carry separate diagnostics: AmbiguousTypeBinding for a name that
matches more than one node with nothing to settle it, and InvalidTypeBinding
for the rest — a resolved type of the wrong NodeClass, or a name and a link that
disagree.
Resolving a relation: companion ReferenceTypes
A link rel names the ReferenceType of the relation it states (§5.1.2), and
uav:refId carries that ReferenceType's definitive ExpandedNodeId (§6.2).
Neither is limited to the handful of base-namespace names the library knows:
any ReferenceType the §5.1.5 local context holds resolves by the same rules.
IWotReferenceTypeResolver (in Opc.Ua.Types) is the capability that supplies
them. It is a separate interface rather than a member of IWotNodeResolver
because a local context describing no ReferenceType has none to offer, and the
library targets frameworks without default interface implementations. The
converter probes for it, and a part that does not offer it contributes nothing
rather than ending the walk.
| Implementation | Where the names come from | Assembly |
|---|---|---|
WotDocumentNodeResolver |
the sibling documents being converted | Opc.Ua.Types |
SnapshotWotNodeResolver |
the registry snapshot's ReferenceType documents | Opc.Ua.WotCon.Server |
AddressSpaceWotNodeResolver |
the ReferenceTypes the Server has loaded | Opc.Ua.WotCon.Server |
WotCompositeNodeResolver keeps the §5.1.5 order here too: the first part that
matches a name settles it, so a set of documents authored together resolves to
itself and loading an unrelated companion model can never change what an
existing document projects to.
OPC 10000-3 gives a ReferenceType two names, so the lookup resolves both:
- a match on the BrowseName reads the reference forward;
- a match on the InverseName reads the same reference backwards, and the
emitted
Referencehas itsIsForwardflag cleared; - a symmetric ReferenceType has one name for both directions and is therefore offered once, forward. Indexing it under both names would make every use of the name ambiguous.
ResolveReferenceTypesAsync returns every match rather than one, because one
namespace may hold a ReferenceType whose BrowseName is the name and another
whose InverseName is. Each match carries the ReferenceType's canonical NodeId,
the name that matched it and the direction that name expressed.
Four outcomes are diagnosed rather than guessed at:
| Outcome | Diagnostic |
|---|---|
The name resolves to nothing and the link carries no uav:refId |
ModelConceptUnresolved |
The name matches more than one ReferenceType and the link carries no uav:refId to settle it (§6.2 requires one exactly here) |
ReferenceTypeAmbiguous |
The name, or the uav:refId, names a Node the local context holds that is not a ReferenceType |
ReferenceTypeNodeClassInvalid |
The name and the uav:refId name different ReferenceTypes, or the uav:refId names none of the candidates |
ModelConceptConflict |
Where the name and the identifier agree, the identifier settles which candidate
was meant and the candidate carries the direction — so uav:refId fixes an
ambiguous relation without the author having to restate the direction.
A document describing a ReferenceType carries both names, so a local context
built from documents alone can answer an inverse relation: uav:inverseName
holds the InverseName and uav:symmetric the Symmetric flag. Both map onto the
projected Node's own Attributes and are restored on the reverse conversion.
A resolved relation is written into the NodeSet as a NodeSet-local NodeId, never
as the portable ExpandedNodeId it resolved to: a NodeSet2 document may only
state a ReferenceType as a local NodeId or as a name it declares in
<Aliases>, and the importer rejects anything else.
Alarms and Conditions
Section 13 maps an OPC 10000-9 Condition to a WoT event affordance for the notification and action affordances for the Condition Methods. Four terms carry it:
| Term | Domain | Meaning |
|---|---|---|
uav:conditionType |
event affordance | The compact model name of the ConditionType the event projects, e.g. ua:LimitAlarmType |
uav:conditionTypeId |
event affordance | The definitive ExpandedNodeId of the same ConditionType |
uav:conditionAction |
action affordance | The Condition Method invoked. Closed set: Acknowledge, Confirm, AddComment, Enable, Disable |
uav:actsOn |
action affordance | The event affordance, in the same document, whose Condition the action acts on |
A projected Condition event derives from the ConditionType it names rather than
from BaseEventType. That is the whole point of the mapping: a Client browsing
a type that fell back to BaseEventType would see none of the Condition state
and could not tell an alarm from an ordinary event.
The runtime projection follows the same rule. An event affordance that carries
uav:conditionType or uav:conditionTypeId materializes under the named
ConditionType, so an OPC UA event filter for that ConditionType, or for one of
its supertypes, can match the event. An action that carries
uav:conditionAction and uav:actsOn is routed to the corresponding OPC
10000-9 Condition Method on the Condition identified by the event affordance.
The two forms follow the hint-plus-pin pattern of Section 5.3.
uav:conditionTypeId is definitive and wins. uav:conditionType is a readable
hint, resolved for the four ConditionTypes Section 13.1 scopes —
ConditionType, AcknowledgeableConditionType, AlarmConditionType and
LimitAlarmType. A name outside that set must be pinned; an unpinned one is
reported rather than guessed. Where a document states both and they name
different types, that is a contradiction rather than a precedence question —
the pin is the definitive identity of the same type the compact name reads —
and it is reported as ConditionTypeConflict.
The converter enforces the four Section 13.3/13.4 conformance rules, each because breaking it yields a document a consumer can read but cannot act on, and also rejects an unresolvable readable ConditionType name:
| Rule | Section | Diagnostic |
|---|---|---|
A Condition event declares EventId in its data |
13.3 | ConditionEventIdMissing |
uav:conditionAction is in the closed set |
13.2 | InvalidConditionAction |
uav:actsOn names a Condition event in the same document |
13.4 | InvalidConditionTarget |
Acknowledge / Confirm / AddComment declare an EventId input |
13.4 | ConditionActionInputMissing |
uav:conditionType names a ConditionType this Binding resolves |
13.2 | UnresolvedConditionType |
uav:conditionType and uav:conditionTypeId name the same type |
13.2 | ConditionTypeConflict |
The ConditionType declares the Method uav:conditionAction names |
13.1, 13.4 | ConditionActionNotDeclared |
A data member is a DataSchema naming one field |
13.3 | EventFieldInvalid |
Condition event data and Condition Methods
The notification's data object carries the Condition state (Section 13.3).
Both directions read one table of the fields OPC 10000-9 declares, so a NodeSet
that does not itself contain ConditionType still projects the complete field
list and a document that authors it still materializes only the fields its own
type adds:
- NodeSet → WoT. The
dataobject is the fields the projected EventType effectively has: the eight mandatoryBaseEventTypefields, then the Condition identity and state fields, then the state each ConditionType subtype adds, then the Variables the projected type declares itself, in the order its References state them. The mandatory base fields and the Condition identity and state fields arerequired; subtype state is present but not required, which is the shape Section 13.5 states. A field the type declares itself also carriesuav:mapToType,uav:valueRank,uav:arrayDimensions,uav:browseNameanduav:modellingRule, because nothing outside the document says what it is.Severityis a per-occurrence member of that schema and nothing else: WoT Binding 1.1 mints no term that states a default severity, so none is emitted. - WoT → NodeSet. Only the members the projected type adds become Nodes:
a member naming an inherited field is already declared by the type it comes
from, and re-declaring it would leave a Server holding two declarations of one
field.
ConditionIdis never materialized at all — it is the NodeId Attribute of the Condition, which is why Section 6.1 selects it with the empty browse path. A member the schema lists inrequiredgets theMandatorymodelling rule and every other one getsOptional.
A Condition Method is the standard Method OPC 10000-9 declares, so an action
carrying uav:conditionAction materializes with that declaration as its
MethodDeclarationId (Acknowledge i=9111, Confirm i=9113, AddComment
i=9029, Enable i=9027, Disable i=9028), takes the base-namespace
BrowseName the declaration has, and becomes a component of the EventType
the pairing names. That is what records the pairing structurally, so the
forward direction reads uav:conditionAction and uav:actsOn back from the
model rather than guessing at them. A pairing OPC 10000-9 does not admit — an
Acknowledge acting on a plain ua:ConditionType, which declares no such
Method — is reported as ConditionActionNotDeclared instead of being
materialized against a Method that is not there.
Going the other way, a Method whose base-namespace BrowseName is one of the
five is annotated when the event it acts on can be named without guessing:
either the EventType holds the Method, or the document projects exactly one
Condition event. With several candidates and no owning type the annotation is
left out and ConditionActionTargetUnresolved is reported — an uav:actsOn
that names the wrong Condition is worse than one that is absent, because a
consumer would acknowledge the wrong alarm. The same diagnostic covers an
occurrence-level Method that neither holds its EventId argument nor states
the standard MethodDeclarationId, because a pairing without an EventId
input is one Section 13.4 rejects.
The ConditionType name is a compact model name, so its prefix is resolved
through the document's @context rather than matched literally: an author may
bind a second prefix to the OPC UA namespace and uav:conditionType still
resolves.
EventId names the Event occurrence, so without it a consumer can receive a
notification but can never identify the occurrence to acknowledge, confirm or
comment on. It is the one hard requirement of Section 13.3: an affordance
carrying uav:conditionType shall declare EventId in its data object and,
where it states a selection, shall select it — the resolved selection is what a
MonitoredItem is created with, so one that omits the field
describes a notification that never carries it. Every other Condition field is
present in data where the affordance selects it and is not otherwise
required; both are ConditionEventIdMissing. Enable and Disable act on the
Condition instance rather than one occurrence and are deliberately exempt from
the input rule.
Shelving, suppression, dialog conditions and ConditionRefresh are outside the
mapping, as Section 13.1 scopes it.
For the converter-default compatibility note, see Condition events derive from their ConditionType.
Current sample limitation: the upstream cavitation signal is proven to raise the
upstream alarm and leave it unacknowledged, but the Pump1 Asset's Supervision
view currently organizes no event affordance, and acknowledgement does not round-trip
because the projected pump actions are Start, Stop and Reset rather than
Condition Methods carrying uav:conditionAction / uav:actsOn.
Compatibility switch for non-portable identifiers
Release 1.1 rejects two identifier forms that OPC 10101 v1.00 permitted. Both are session-local: a document carrying either binds to the wrong namespace as soon as the server's namespace table is reordered, which is exactly what a document meant to be stored and re-read must not do.
| Rejected in release 1.1 | Permitted in v1.00 | Portable form to use instead |
|---|---|---|
ns=<index> in any NodeId-valued term |
§ 6.2 | nsu=<NamespaceUri>;<idtype>=<id> |
a numeric namespace prefix in uav:browseName / uav:browsePath |
§ 6.5.3 | a context-bound non-numeric prefix, or nsu=<NamespaceUri>;<Name> |
The NodeId rule applies to every NodeId-valued term: uav:id, uav:hasComponent,
uav:componentOf, uav:mapToNodeId, uav:mapToType, uav:refId, and the href
of a form.
So a v1.00 document written like this:
{
"uav:id": "ns=3;i=1005",
"uav:browseName": "3:Identification",
"forms": [{ "href": "/?id=ns=3;s=Pump1.Temperature" }]
}
is rewritten for 1.1 as:
{
"uav:id": "nsu=http://example.com/UA/Pumps/;i=1005",
"uav:browseName": "nsu=http://opcfoundation.org/UA/DI/;Identification",
"forms": [{ "href": "/?id=nsu=http://example.com/UA/Pumps/;s=Pump1.Temperature" }]
}
The namespace URI is written out, so the meaning no longer depends on the order of the table the reader happens to hold.
A document carrying either form fails to convert, reporting NonPortableIdentity
or NonPortableQualifiedName as an error. Rewriting the document is the fix. While
that is in progress, WotNodeSetConverterOptions.AllowNonPortableIdentifiers
downgrades both errors to warnings, so the non-portable values stay visible rather
than being silently accepted, and each is interpreted exactly as v1.00 defined it:
var options = new WotNodeSetConverterOptions
{
AllowNonPortableIdentifiers = true
};
WotConversionResult<UANodeSet> result =
WotNodeSetConverter.ToNodeSetResult(document, options);
foreach (WotDiagnostic diagnostic in result.Diagnostics)
{
// NonPortableIdentity / NonPortableQualifiedName arrive as warnings here
// instead of errors, naming the term and the value that has to be rewritten.
Console.WriteLine($"{diagnostic.Severity}: {diagnostic.Code} {diagnostic.Message}");
}
The option defaults to false, which matches the release 1.1 validator. Leave it at
the default once the documents are rewritten; it exists to keep a v1.00 corpus
readable during migration, not as a supported long-term mode.