Files
UA-.NETStandard/docs/WoTNodeSetConversion.md
romanett 2ba58a8cbf WoT: move GeneratesEvent off Object instances (OPC 10000-3 §7.15) (#4706)
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>
2026-10-08 20:57:20 +00:00

881 lines
68 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# WoT / NodeSet conversion
`Opc.Ua.Wot.WotNodeSetConverter` converts OPC UA NodeSet2 documents to
WoT Thing Models / Thing Descriptions and materializes WoT documents back
to NodeSet2. The converter preserves a byte-exact `uav:nodeSet` envelope
when requested, uses the structured `uav:nodes` projection when the
readable vocabulary is not complete, and otherwise synthesizes NodeSet2
from the readable WoT terms.
## Contents
- [Which specification revision this tracks](#which-specification-revision-this-tracks)
- [Importable output](#importable-output)
- [Modelling rules and the two placeholder identifiers](#modelling-rules-and-the-two-placeholder-identifiers)
- [WoT to NodeSet defaults](#wot-to-nodeset-defaults)
- [Portable identity and preservation semantics](#portable-identity-and-preservation-semantics)
- [Verified linked document sets](#verified-linked-document-sets)
- [Generated NodeIds follow Annex G.1](#generated-nodeids-follow-annex-g1)
- [Preservation digests and the two things that can be measured](#preservation-digests-and-the-two-things-that-can-be-measured)
- [Unmapped reference vocabulary is residue](#unmapped-reference-vocabulary-is-residue)
- [Condition events derive from their ConditionType](#condition-events-derive-from-their-conditiontype)
- [Event fields come from the type, not only from the document](#event-fields-come-from-the-type-not-only-from-the-document)
- [Resolving a stated selection needs the asynchronous conversion](#resolving-a-stated-selection-needs-the-asynchronous-conversion)
- [DataType definitions (Section 6.11)](#datatype-definitions-section-611)
- [Model and platform vocabulary (Section 6)](#model-and-platform-vocabulary-section-6)
- [Conformance claims and strict mode (Sections 4.1, 6.1, 6.6 and 11)](#conformance-claims-and-strict-mode-sections-41-61-66-and-11)
- [Authoring a claim and processing one are different acts](#authoring-a-claim-and-processing-one-are-different-acts)
- [The `uav:nodes` record grammar is not vocabulary](#the-uavnodes-record-grammar-is-not-vocabulary)
- [Generated documents state the revision they were generated against](#generated-documents-state-the-revision-they-were-generated-against)
- [NodeClass and ReferenceType vocabulary](#nodeclass-and-referencetype-vocabulary)
- [Engineering units, ranges and scaling (Sections 6.4 and 6.4.1)](#engineering-units-ranges-and-scaling-sections-64-and-641)
- [Localized text (Section 9.1.1)](#localized-text-section-911)
- [ValueRank and ArrayDimensions (Sections 7 and 9.1)](#valuerank-and-arraydimensions-sections-7-and-91)
- [Method arguments (Section 9.1)](#method-arguments-section-91)
- [ReferenceTypes and relations (Sections 5.1.2, 5.3 and 6.2)](#referencetypes-and-relations-sections-512-53-and-62)
- [`uav:componentOf` and its `ua:ComponentOf` alias](#uavcomponentof-and-its-uacomponentof-alias)
- [Projection documents and the OPC 10100-1 v1.02 asset surface](#projection-documents-and-the-opc-10100-1-v102-asset-surface)
## Which specification revision this tracks
This implementation tracks **WoT Binding revision 1.1**. The revision is
stated once, as `Opc.Ua.Wot.WotBindingConformance.CurrentRevision`, and
everything that depends on it — the `uav:bindingVersion` values strict
conformance accepts, the versioned artifact base
`http://opcfoundation.org/UA/WoT-Binding/v1.1/`, and the closed Section 11
name set — derives from that constant. No public behaviour is keyed to an
upstream commit: a revision is a published contract, a commit is not.
The `uav` prefix binds to `http://opcfoundation.org/UA/WoT-Binding/`. The
reader matches the compact `uav:` spelling and never consults the IRI a
document binds the prefix to, in permissive *and* in strict mode, because
strictness is about the vocabulary a document uses and not about the IRI it
declares that vocabulary under. A document written against the earlier
`http://opcfoundation.org/UA/WoT/v1#` draft binding therefore stays
readable; that leniency is deliberate and is covered by a test.
The specification's worked examples are vendored as test fixtures under
`tests/Opc.Ua.Types.Tests/Wot/Assets`, and *those* do record the exact
source. `spec-examples.manifest.json` beside them names the source
repository, branch and commit together with the size and SHA-256 of every
example, and `WotSpecFixtureManifestTests` fails the build when the two
disagree. See [How this is checked](WotBindings.md#how-this-is-checked)
for the drift check and the regeneration step.
## Importable output
A NodeSet2 document may write a standard name such as `HasComponent` or
`Double` wherever a NodeId is expected, but only if it declares that name
in its own `<Aliases>` table; the importer reports `BadNodeIdInvalid` for a
name it cannot find. Both halves of the conversion produce such names —
synthesis writes the readable names directly, and a restore reproduces
whatever spelling the document it restores from used — so every converted
NodeSet is passed through an alias-completion pass before it is returned.
That pass is not a WoT concept: `NodeSetAliasCompleter` sits beside
`UANodeSet` itself and declares whatever the `INodeSetAliasResolver` it is
handed knows. The WoT Binding's own policy is one object,
`WotNodeSetAliases`, which states that a converted document writes the
standard base-namespace names and delegates to the single table of them
(`NodeSetStandardAliases`) rather than repeating any.
The pass declares only names that policy resolves, appended after the
declarations the document already carried in ascending ordinal order of
the alias. It is idempotent, so a byte-exact `uav:nodeSet` restore stays
byte-exact, and it never rewrites a name: a vendor alias the document uses
but does not declare still fails the import with the message that names
it, rather than being quietly discarded.
Completion is a decision a *producer* makes about a document it writes.
Comparison makes none of its own: `NodeSetComparer.CompareEquivalent`
resolves each document through its own declarations first and asks
`NodeSetComparisonOptions.AliasResolver` about the rest. That property
defaults to nothing at all, so a comparison of two arbitrary NodeSets does
not report a document that writes `HasComponent` without declaring it —
which no Server could load — as equivalent to one that writes `i=47`. The
WoT conversion injects `WotNodeSetAliases` through
`WotNodeSetConverterOptions.ToComparisonOptions`, so its Section 9.2
completeness check reads the names the Binding writes, and the comparison
itself stays free of any knowledge of WoT.
That matters beyond tidiness, because the runtime registry materialization
path is `ConvertAsync` → serialize → `UANodeSet.Read` → `Import`. Every
vendored specification example that converts is run through exactly that
sequence by `WotNodeSetImportTests`, and so is every preservation mode.
### Modelling rules and the two placeholder identifiers
OPC 10000-5 assigns `OptionalPlaceholder` the identifier `11508` and
`MandatoryPlaceholder` the identifier `11510`; `11509` is not a
ModellingRule Object at all. The four rules therefore map as:
| `uav:modellingRule` | NodeId |
| --- | --- |
| `Mandatory` | `i=78` |
| `Optional` | `i=80` |
| `OptionalPlaceholder` | `i=11508` |
| `MandatoryPlaceholder` | `i=11510` |
Both conversion directions derive these identifiers from one pair of constants,
so a round trip preserves the modelling rule it started with.
## WoT to NodeSet defaults
The table below lists the defaults the current WoT-to-NodeSet
materializer applies when the WoT input lacks information needed for a
NodeSet2 projection. Rows marked **Fails** emit an error diagnostic and
`ToNodeSet` throws. Rows marked **Default** substitute the listed value;
warnings or informational diagnostics are noted where the code emits
them.
| Missing WoT input | Behaviour | Materialized value |
|---|---|---|
| Convertible content: no `uav:nodeSet`, no `uav:nodes`, and neither a Thing Model nor a Thing Description kind | **Fails** | `NoConvertibleContent` error; no NodeSet is returned. |
| Model namespace: root `uav:id` has no `nsu=<NamespaceUri>;...` part and document `id` is absent | **Default** | `NamespaceUris = ["urn:opcua:wot:synthesized"]` and `Models[0].ModelUri = "urn:opcua:wot:synthesized"`. This is intentionally synthetic and should be replaced by an authored namespace in portable documents. |
| Root local name: root `uav:browseName` is absent | **Default** | Sanitized `title` (letters, digits, `_`, `-`) when non-empty; otherwise `Thing`. |
| Root `uav:id` | **Default** | Deterministic NodeId by Annex G.1: `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>`, and an informational `GeneratedNodeId` diagnostic. |
| Root `uav:browseName` | **Default** | `1:<rootLocal>`. |
| Root `title` | **Default** | Root `DisplayName` becomes `<rootLocal>`. |
| Thing Model root event annotation `uav:eventType` | **Default** | Root is a non-abstract `UAObjectType` with inverse `HasSubtype` to `BaseObjectType` (`i=58`). If the event annotation is present, the default supertype is `BaseEventType` (`i=2041`). |
| Thing Description root type information | **Default** / **Bound** / **Fails** | Absent: root is a `UAObject` with `HasTypeDefinition` to `BaseObjectType` (`i=58`). Present: a `ua:HasTypeDefinition` link (WoT Binding Section 5.2.1) whose `href` is the ExpandedNodeId of the type, and/or a compact model name in `@type`, binds the root to that type so the converter reuses the existing type rather than defining a second one. The two forms are resolved against the Section 5.1.5 local context — the sibling documents of the conversion first, a loaded AddressSpace as the fallback — supplied through `IWotNodeResolver`. A binding that names a type the local context does not hold **fails** (`UnresolvedTypeBinding`) rather than falling back to `BaseObjectType`; two bindings that disagree, an ambiguous name with nothing to settle it, or a resolved type of the wrong NodeClass **fail** as `AmbiguousTypeBinding`. A compact name is a binding when its namespace is one the local context holds; any other `@type` member is ordinary annotation and is retained as residue. |
| Root `description` | **Default** | No `Description` field is materialized. |
| Property affordance `uav:browseName` | **Default** | The affordance map key is used as the local name and BrowseName `1:<key>`. |
| Property affordance `uav:id` | **Default** | Deterministic NodeId by Annex G.1: `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>/nsu=<escaped model NamespaceUri>;<propertyLocal>`. |
| Property DataSchema `type` or an unrecognized `type` | **Default** | The canonical table of WoT Binding §6.11.4: `boolean` → `Boolean`, `integer` → the **abstract** `Integer` (`i=27`), `number` → the **abstract** `Number` (`i=26`), `string` → `String`, refined by `contentEncoding: base64` → `ByteString`, `format: date-time` → `DateTime`, `format: uuid` → `Guid`, `format: uri` → `UriString`. An explicit `uav:dataTypeId` or `uav:mapToType` outranks the inference. Anything unrecognized falls back to `BaseDataType` (`i=24`). A bare `integer` or `number` is deliberately abstract: the schema states only that the value is whole or numeric, and a concrete width is recovered from an annotation rather than guessed. |
| Property `readOnly` and `writeOnly` | **Default** | Missing flags mean read/write access (`CurrentRead \| CurrentWrite`, value `3`). If both flags are `true`, the zero-access result is coerced to `CurrentRead` (`1`); this is an arbitrary safety default and should be specified explicitly. |
| Property `title` | **Default** | No `DisplayName` field is materialized for the variable. A `titles` map materializes one `LocalizedText` per locale, the default locale's entry first (Section 9.1.1). |
| Property `description` | **Default** | No `Description` field is materialized for the variable. A `descriptions` map materializes one `LocalizedText` per locale. |
| Property `uav:valueRank` (Sections 7, 9.1) | **Default** / **Fails** | Absent: `ValueRank` `-1` (Scalar), which is what a NodeSet omits. Present: the stated rank, so `-3`, `-2`, `-1`, `0` and a fixed positive rank stay distinct. Not an integer literal, or below `-3`: `InvalidValueRank` error. |
| Property `uav:arrayDimensions` (Sections 7, 9.1) | **Default** / **Fails** | Absent: no `ArrayDimensions` attribute. Present: the ordered bounds, with `0` meaning a dimension whose length is not fixed. Not an array of non-negative integers, a length other than a fixed `uav:valueRank`, or any dimension against a rank that fixes none: `InvalidValueRank` error. |
| Property type definition | **Default** | `HasTypeDefinition` to `BaseDataVariableType` (`i=63`). An affordance that binds itself to `PropertyType` (`i=68`) is held by `HasProperty` rather than `HasComponent`, which is the only ReferenceType OPC 10000-3 reaches a Property through. |
| Action affordance `uav:browseName` | **Default** | The affordance map key is used as the local name and BrowseName `1:<key>`. |
| Action affordance `uav:id` | **Default** | Deterministic NodeId by Annex G.1: `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>/nsu=<escaped model NamespaceUri>;<actionLocal>`. |
| Action `title` | **Default** | No `DisplayName` field is materialized for the method. |
| Action `input` / `output` (Section 9.1) | **Default** / **Fails** | Absent: the Method is materialized with no argument Property. Present: the schema becomes an `InputArguments` / `OutputArguments` Property (`Argument[]`, `DataType` `i=296`, `ValueRank` `1`) held by `HasProperty`, with NodeId `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>/nsu=<escaped model NamespaceUri>;<actionLocal>/<InputArguments\|OutputArguments>` (Annex G.1; the standard child keeps its bare base-namespace name). A schema that names one DataType — through `uav:mapToType`, `uav:dataTypeId`, `uav:dataTypeName` or an inline `uav:dataTypeDefinition` — is one argument, named from its `uav:browseName` or `title` and otherwise `Input` / `Output`; an object schema's members are the arguments. Each member's DataType resolves by the property rules above, `uav:valueRank` defaults to `-1` and `uav:arrayDimensions` and `description` are carried onto the `Argument`. Order comes from `uav:fieldOrder` (Section 6.11.4); a single member needs none, and an `Acknowledge`, `Confirm` or `AddComment` action takes the fixed OPC 10000-9 order `EventId`, `Comment`. A multi-member schema with no order **fails** (`MethodArgumentOrderAmbiguous`) rather than using JSON member order, and one whose `uav:fieldOrder` does not list every member exactly once, or that is not a JSON object, **fails** (`MethodArgumentSchemaInvalid`). A reported schema is still carried verbatim through residue, so a rejected document never loses the signature it authored. |
| Event affordance `uav:browseName` | **Default** | The affordance map key is used as the local name and BrowseName `1:<key>`. |
| Event affordance `uav:id` | **Default** | Deterministic NodeId by Annex G.1: `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>/nsu=<escaped model NamespaceUri>;<eventLocal>`. |
| Event `title` | **Default** | No `DisplayName` field is materialized for the event type. |
| Event type abstraction/supertype | **Default** | Event affordances materialize as non-abstract `UAObjectType` nodes with inverse `HasSubtype` to `BaseEventType` (`i=2041`), unless the affordance carries `uav:conditionType` or `uav:conditionTypeId`. A Condition event derives from the named ConditionType instead (WoT Binding Section 13.2). A `uav:conditionType` and a `uav:conditionTypeId` naming different types **fail** (`ConditionTypeConflict`). |
| Event source (OPC 10000-3 §7.15) | **Default** | A Thing Model's projected ObjectType or VariableType carries a forward `GeneratesEvent` reference to each event type. A Thing Description projects an Object or Variable, which cannot be the source of `GeneratesEvent`, so its events are declared by its type instead: an event the bound type already declares (matched by qualified BrowseName against the local context's declarations) needs nothing more, and every other one is declared by a synthesized non-abstract `<rootLocal>Type` (BrowseName `1:<rootLocal>Type`, NodeId by Annex G.1 from that name) that derives from the bound type, or from `BaseObjectType` / `BaseDataVariableType` when the document binds none, and becomes the instance's `HasTypeDefinition`. Going from a NodeSet, an instance's event affordances are the events its type definition and that type's supertypes generate; a forward `GeneratesEvent` on the instance itself is still read. A single readable document folds a type that only carries the instance's events back into the instance - its supertype becomes the type binding - while a document set gives it a Thing Model of its own. |
| Event `data` (Section 13.3) | **Default** / **Fails** | Absent, or a member naming a field the projected type inherits: no Node is materialized, because `BaseEventType` and the ConditionTypes already declare those fields and `ConditionId` is the Condition's NodeId Attribute rather than a Variable. A member the type adds becomes a Property (`HasTypeDefinition` `PropertyType`, NodeId `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>/nsu=<escaped model NamespaceUri>;<eventLocal>/nsu=<escaped model NamespaceUri>;<fieldLocal>` (Annex G.1)) whose DataType resolves by the property rules above, with `uav:valueRank` defaulting to `-1`, `uav:arrayDimensions` and `description` carried across, and `HasModellingRule` `Mandatory` when the schema lists the member in `required` and `Optional` otherwise. A member that is not a DataSchema, or that reaches a BrowseName another member already reached, **fails** (`EventFieldInvalid`) and is carried verbatim through residue. |
| `uav:conditionAction` / `uav:actsOn` (Section 13.4) | **Default** / **Fails** | Absent: the Method is a component of the Thing with no `MethodDeclarationId`. Present and admitted by the ConditionType the target event projects: the Method takes the base-namespace BrowseName of the named Condition Method, carries the OPC 10000-9 declaration as its `MethodDeclarationId` (`Acknowledge` `i=9111`, `Confirm` `i=9113`, `AddComment` `i=9029`, `Enable` `i=9027`, `Disable` `i=9028`) and becomes a component of the EventType instead of the Thing. A Method the projected ConditionType does not declare — `Acknowledge` or `Confirm` against a plain `ua:ConditionType` — **fails** (`ConditionActionNotDeclared`). |
| Unknown event member `uav:severity` | **Default** | WoT Binding 1.1 defines no such term. A document that carries one is consumed permissively: the member is ordinary unknown residue, nothing is materialized from it, and no `Severity` Property is synthesized. Strict authoring reports it as an unknown term. |
| `uav:modellingRule` on a property or action | **Default** | No `HasModellingRule` reference is materialized. |
| `uav:hasComponent` / `uav:componentOf` entry has no matching typed ReferenceType link | **Default** | `HasComponent` is used for the component reference. |
| Link `rel` names a ReferenceType the local context holds (Sections 5.1.2, 5.1.5 and 6.2) | **Default** / **Fails** | The relation is created with that exact ReferenceType, stated as a NodeSet-local NodeId. A `rel` matching the ReferenceType's **InverseName** clears `IsForward`; a symmetric ReferenceType reads forward under its BrowseName in both directions. A `rel` matching more than one ReferenceType with no `uav:refId` to settle it: `ReferenceTypeAmbiguous` error. A `rel` or `uav:refId` naming a Node of another NodeClass: `ReferenceTypeNodeClassInvalid` error. A `rel` and a `uav:refId` naming different ReferenceTypes, or a `uav:refId` naming none of an ambiguous name's candidates: `ModelConceptConflict` error. Nothing falls back to `HasComponent` or to a standard alias. |
| `uav:inverseName` / `uav:symmetric` on a document that projects a ReferenceType | **Default** | Absent: the ReferenceType is materialized with no `InverseName` and `Symmetric` `false`. Present: they become the projected Node's `InverseName` (tagged with the document's default locale) and `Symmetric` Attributes, which is what lets a local context built from documents alone resolve an inverse relation. |
| Binding link has no resolvable model-name relation and no `uav:refId` | **Default** | The link maps to `Organizes`. A typed link names its ReferenceType directly in `rel` (`ua:HasComponent`, `ua:HasInterface`, `ua:NonHierarchicalReferences`). |
| Reference link points to another Thing by URI and no resolver is supplied or the resolver cannot find `uav:id` | **Default** | The reference is omitted and a warning diagnostic is emitted; no placeholder NodeId is generated. |
| Invalid namespace-qualified `uav:id` / `uav:browseName` syntax or unbound compact-name prefix | **Fails** | An error diagnostic is emitted; `ToNodeSet` throws even though synthesis continues far enough to collect diagnostics. |
| Event affordance says `@type: uav:eventType` and carries `uav:isEvent` | **Default** | WoT Binding 1.1 defines event identity with the `@type` annotation and defines no `uav:isEvent` term. The unknown member is preserved as residue and reported as an unknown term in strict authoring. |
| `uav:isComposite` (Section 6.1) | **Default** / **Fails** | Absent: the type is treated as atomic and no `HasComponent` walk is forced. Malformed (non-boolean): `InvalidModelVocabularyValue` error and `ToNodeSet` throws. Present and valid: the flag has no distinct readable NodeSet structure, so it is carried verbatim through the `uav:nodes` residue and restored on the reverse conversion. |
| `uav:contains` (Section 6.3) | **Default** / **Fails** | Absent: sub-components come from links only. Malformed (not an array, or an entry that does not match a link `uav:refName` declared on the same type): `InvalidContainment` error. Present and valid: preserved via residue. |
| `uav:containedIn` (Section 6.3) | **Default** / **Fails** | Absent: no parent is recorded. Malformed (not a non-empty string, or naming the type itself, which is a cycle): `InvalidContainment` error. The reciprocal "the named composite exists" check is cross-document and out of scope for the single-document converter, which validates range and self-cycle only. Present and valid: preserved via residue. |
| `uav:unitProperty` (Section 6.4) | **Default** / **Fails** | Absent: the affordance names no unit Property. Malformed (not a canonical RFC 6901 pointer of the form `/properties/<name>`, naming the affordance that carries it, or resolving to something other than a sibling property affordance whose DataSchema `type` is `string`): `InvalidUnitPointer` error. Present and valid: the named affordance becomes the annotated Variable's own `EngineeringUnits` Property (`HasProperty`), unless it states its own `uav:componentOf`. |
| `uav:engineeringUnits` (Section 6.4.1) | **Default** / **Fails** | Absent: no `EUInformation` value is materialized. Malformed (not an object carrying `namespaceUri`, an integer `unitId` and `displayName`): `InvalidEngineeringUnits` error. Present and valid: the affordance's Variable gets `DataType` `EUInformation` (`i=887`) — unless `uav:mapToType` pins another — and a `Value` holding the `EUInformation` in its default XML encoding (`i=888`). |
| `minimum` / `maximum` (Section 6.4.1) | **Default** / **Fails** | Absent, or only one of the two: no `EURange` Property is materialized. `minimum` above `maximum`: `InvalidRangeValue` error. Present and valid: an `EURange` Property (`Range` `i=884`, `HasTypeDefinition` `PropertyType`, `HasModellingRule` `Mandatory`, NodeId `ns=1;s=/nsu=<escaped model NamespaceUri>;<rootLocal>/nsu=<escaped model NamespaceUri>;<propertyLocal>/EURange` (Annex G.1)) holding the interval, or the value of the `EURange` affordance the document authored itself. |
| `uav:instrumentRange` (Section 6.4.1) | **Default** / **Fails** | Absent: no `InstrumentRange` Property. Malformed, or an engineering range not contained in it: `InvalidRangeValue` error. Present and valid: an `InstrumentRange` Property (`Range` `i=884`, `HasModellingRule` `Optional`) holding the interval. |
| `uav:scaleFactor` (Section 6.4) | **Default** / **Fails** | Absent: identity scaling (factor `1`). Malformed (not a non-zero number): `InvalidModelVocabularyValue` error. Present and valid: preserved via residue. It is a static presentation and transport transform, never derived from — nor used to derive — `EngineeringUnits`, `EURange` or `InstrumentRange`. |
| `uav:decimalPlaces` (Section 6.4) | **Default** / **Fails** | Absent: no rounding is recorded. Malformed (not an integer greater than or equal to zero; `2.0` is rejected as a non-integer literal): `InvalidModelVocabularyValue` error. Present and valid: preserved via residue. |
| `titles` / `descriptions` (Section 9.1.1) | **Default** / **Fails** | Absent: the singular member materializes one `LocalizedText` using its effective language. Present: every plural entry is authoritative. The singular member repeats the default-locale entry when present; otherwise it repeats the code-point-first language entry and uses a node-local language-neutral override. A missing default locale is valid. A missing singular member, an inconsistent fallback, or an incorrectly language-tagged fallback produces `InvalidLocalizedText`. |
| `uav:semanticId` (Section 6.7) | **Default** / **Fails** | Absent: no semantic reference is recorded. Malformed (not an absolute IRI with a scheme): `NonAbsoluteIri` error. Present and valid: preserved via residue. |
| `uav:metadata` (Section 6.7) | **Default** | Absent: nothing is recorded. Present: opaque; carried verbatim through residue, never validated and never a reason to reject the document (Section 6.7). |
| `uav:propertyConfiguration` (Section 6.7) | **Default** | Absent: nothing is recorded. Present: opaque per-affordance configuration; carried verbatim through residue and never validated. |
| `uav:actionConfiguration` (Section 6.7) | **Default** | Absent: nothing is recorded. Present: opaque per-affordance configuration; carried verbatim through residue and never validated. |
| `uav:eventConfiguration` (Section 6.7) | **Default** | Absent: nothing is recorded. Present: opaque per-affordance configuration; carried verbatim through residue and never validated. |
| `uav:includeInherited` (Section 6.8) | **Default** / **Fails** | Absent: no inheritance-span flag is recorded. Malformed (non-boolean): `InvalidModelVocabularyValue` error. Present and valid: preserved via residue. |
| `uav:additionalProperties` (Section 6.8) | **Default** / **Fails** | Absent: no open-content flag is recorded. Malformed (non-boolean): `InvalidModelVocabularyValue` error. Present and valid: preserved via residue. |
| `uav:browsePathAnchor` (Section 5.1.4) | **Default** / **Fails** | Absent: a relative `uav:browsePath` resolves against the nearest enclosing `uav:id`. Malformed (not an ExpandedNodeId): `ValidationError` error; the session-local `ns=<index>` form is reported `NonPortableIdentity` (an error unless `AllowNonPortableIdentifiers` is set). Present and valid: preserved via residue. |
## Portable identity and preservation semantics
The following sections describe generated NodeId identity and the distinct
measurements used for preserved JSON values.
### Verified linked document sets
`WotNodeSetConverter.FromNodeSetDocumentsAsync` exports a linked set and
reconstructs it before returning success. Each document root owns a disjoint
partition of the source. Methods, their argument Properties, and nested
Variables stay with their owner; nested Objects and EventTypes have separate
document roots. References can point at Nodes in other partitions.
Complete readable partitions omit `uav:nodes`. A partition requiring exceptional
preservation carries its complete authoritative `uav:nodes` representation,
not a delta to overlay on readable siblings. The namespace/model/alias tables,
values, permissions, and extensions remain part of the fidelity check. Duplicate
native ownership, conflicting shared headers, and attempts to add readable Nodes
to another native partition are rejected.
`WotNodeSetConverter.CompareDocumentSet` compares the reconstructed set with
its source, normalizing only the order of top-level Node records that partitioning
can regroup. Reference direction and order, definition-field and argument order,
attributes, values, and metadata remain compared. The synchronous
`FromNodeSetDocuments` API produces the readable candidate; use the asynchronous
API when verified reconstruction is required.
`MergeNodeSetPartitions` applies the same bounded ownership and header rules
to already converted partitions without modifying its inputs. The registry
uses this when preparing one runtime source from several documents that declare
the same model. Shared model ownership joins their activation closure; unrelated
readable documents do not become one atomic closure merely because their NodeIds
use the same namespace.
Generated Double constants, ranges, and sampling intervals use the canonical
shortest round-trip spelling on every supported framework. This avoids
platform-specific formatter output being rejected as an over-precise authored
literal. Validation of received numeric literals remains unchanged.
```csharp
WotConversionResult<WotDocumentSet> exported =
await WotNodeSetConverter.FromNodeSetDocumentsAsync(
nodeSet, "pump", nodeResolver: resolver, cancellationToken: ct);
if (!exported.Success)
{
throw new InvalidOperationException(string.Join(Environment.NewLine, exported.Diagnostics));
}
using WotDocumentSet documents = exported.Value!;
foreach (WotDocumentSetEntry entry in documents.Entries)
{
Console.WriteLine($"{entry.Href}: {entry.Document.Utf8Json.Length} bytes");
}
```
Opaque preservation is not limited to `uav:nodes`. Extension members and WoT
metadata without a native NodeSet mapping are retained as JSON residue while
the converter processes the document's supported readable members. For example,
an unsupported future `uav:nodes` grammar is not parsed: the converter reports
the unsupported profile and retains the complete object as opaque residue
instead of discarding it or guessing how to interpret its records. This does
not bypass validation of supported content; malformed supported grammars and
invalid archival digests remain errors.
When an archival `uav:nodeSet` is present, known readable facts are checked
against that baseline and conflicts are reported rather than overwritten.
Routing-only enrichment does not grant permission to add or replace archived
model facts.
### Generated NodeIds follow Annex G.1
A document that authors no `uav:id` gets a **generated** NodeId, and the
generation is the Annex G.1 formula rather than a convenience of this
implementation:
> `GeneratedNodeId(U, P) = "nsu=" + U + ";s=" + P`
`U` is the NamespaceUri the synthesized Node is created in, and `P` is the
Node's absolute browse path in OPC 10000-4 Annex A.2 relative-path syntax: each
element is preceded by `/`, an element of the base OPC UA namespace is written
bare, any other element is `nsu=<percent-encoded NamespaceUri>;<name>`, and the
Annex A.2 reserved characters `&/.<>:#!` are escaped with `&` inside a name. A
NodeSet file carries the same identity in its NodeSet-local spelling,
`ns=1;s=<P>`, because namespace index 1 is `U`; the reverse mapping renders it
back as `nsu=U;s=P`.
`WotPortableIdentity.GenerateNodeId` / `GenerateBrowsePath` is the single
implementation, so a conversion and a published Annex G.1 vector measure the
same function. The same class answers `IsPortableNodeId`,
`IsPortableQualifiedName` and `IsResolvableBrowsePath`, which is what the
conversion's Section 5.1.1 and 5.1.4 validation calls.
The leading separator, per-element namespace qualification and escaping make
the generated identity injective. A member named `A/B` of `Root` has a different
identifier from a member named `B` of `Root/A`, and a base-namespace
`InputArguments` has a different identity from a model member with the same
name. A document-authored `uav:id` always wins over generation; author one when
the identity must be fixed independently of its browse path.
### Preservation digests and the two things that can be measured
Annex G distinguishes three measurements over a JSON value, and this
implementation keeps them apart. Two of them are digests; the third is a size.
* **A digest over retained bytes.** The `Sha256` of a `WoTJsonResidue` member is
the SHA-256 of the **decoded residue bytes exactly** — the bytes the producer
encoded, and the bytes a verifier decoded. Nothing is canonicalized, reordered,
re-escaped or re-formatted before digesting, in either direction, and a
mismatch is a mismatch: the entry is corrupt, not merely differently spelled.
A residue member exists to carry a value Section 6.6 forbids a consumer to
reformat, so a digest over a re-serialization would pin a value no party is
allowed to produce. The `sha256` of a `uav:nodeSet` envelope and the
`uav:sourceDigest` of a projection source are of the same kind.
* **Equality over a JSON value.** RFC 8785 (JCS) is used where two JSON *values*
are compared for equality — the Section 9.4 conflict test that asks whether a
residue entry holds the same value as a member the readable mapping already
produced. `WotJsonCanonicalizer` is that form, and `WotDocument.ToCanonicalUtf8`
writes it: object members sorted by the UTF-16 code units of their names,
the minimal string escaping of ECMAScript `JSON.stringify`, and numbers in the
ECMAScript form of their IEEE-754 double value. A reordered object, an
equivalent escape and `1.0` beside `1` are therefore one value and not a
conflict. A number outside the interoperable domain of RFC 8259 §6 — a literal
carrying more precision than a double holds, such as `9007199254740993` — is
**diagnosed** rather than canonicalized, and the conflict test falls back to
comparing the two as written: that can report a conflict JCS would not, but it
never reports two different values as one. No digest is taken over this form.
* **A size over the compact received form.** The opaque-object size bound of
Section 6.6 is measured over the **compact received form** of Annex G.4: the
received text of the value with insignificant whitespace removed — the four
RFC 8259 whitespace characters outside a string literal — and nothing else
changed. Member order is the received order, numbers keep the lexical form
they were written in, and strings keep the escapes they were written with.
Deliberately *not* a canonical re-serialization: measuring one would oblige a
consumer to produce exactly the reordered, renumbered value the preservation
rule forbids, and an "almost-JCS" measurement — compact separators but a
language's own `double` formatting — is what two implementations disagree
about in practice. Whitespace removal outside string literals is decidable by
a scanner with one bit of state, so two implementations that received the same
bytes measure the same number. The depth and key-count bounds are measured
over the parsed value, where formatting cannot matter.
### Unmapped reference vocabulary is residue
The Binding vocabulary does not define `uav:capability`,
`uav:componentModel`, `uav:reference`, `uav:congruentType`,
`uav:congruentTypeName` or `uav:nameNamespace`. The converter treats them as
unmapped JSON and carries them through the Section 9 `uav:nodes` residue
mechanism without creating References or emitting a permissive-mode diagnostic.
Use the following Binding forms to state those relationships:
| Unmapped term | Binding form |
| --- | --- |
| `uav:componentModel` | link `rel` names `ua:HasComponent` directly |
| `uav:capability` | link `rel` names `ua:HasInterface` directly |
| `uav:reference` | link `rel` names `ua:NonHierarchicalReferences` directly |
| `uav:congruentType` + `uav:congruentTypeName` | Section 5.2.1 type binding with `@type` and a `ua:HasTypeDefinition` link |
See [Resolving a type binding: the local context](WotBindings.md#resolving-a-type-binding-the-local-context)
for the Section 5.2.1 binding rules.
### Condition events derive from their ConditionType
A projected event normally derives from `BaseEventType` (`i=2041`). If the event
affordance carries `uav:conditionType` or `uav:conditionTypeId`, Section 13.2
instead makes the event type derive from the named ConditionType. The Condition
mapping is summarized in [Alarms and Conditions](WotBindings.md#alarms-and-conditions).
This changes event-filter behaviour. A client filtering for `BaseEventType`
subtypes still matches the projected Condition event, but a client that assumed
the exact immediate supertype was `BaseEventType`, or that selects
Condition-specific fields, observes a different type hierarchy and field set.
### Event fields come from the type, not only from the document
The fields a notification carries are declared by `BaseEventType` and, for a
Condition, by the ConditionTypes of OPC 10000-9 — types a converted NodeSet
almost never contains. Both directions therefore read one table of those
declarations rather than the Nodes:
- going **from** a NodeSet, an event affordance's `data` object states the
effective field list of the projected type — the inherited fields in
inheritance order, then the Variables the type declares itself in the order
its References state them — so a document describes the notification a
consumer actually receives rather than an empty schema;
- going **to** a NodeSet, only the members the projected type *adds* become
Nodes, because re-declaring an inherited field would leave a Server holding
two declarations of one field.
A NodeSet holding an Object and the EventType it raises is projected about the
**Object**: an EventType something else generates is a declaration that Node
uses, not the subject of the document.
The `data` object states what a notification *carries*; the affordance's `tm:ref`
and `uav:eventSelectClauses` state what a MonitoredItem *asks for* (Section 6.1).
The schema is derived from the type; the request is a link to the EventType
definition the fields come from, refined by the clauses the affordance writes,
and where the affordance states neither a consumer selects the eight mandatory
`BaseEventType` fields.
Going **from** a NodeSet, an EventType Node becomes a Thing Model of its own
whose root carries `uav:eventType`, the portable `uav:id`, and the effective
`data` schema with a `uav:fieldOrder` on every walked object of more than one
property — which is exactly the EventType definition a fast path derives its
baseline from. Where a document set carries that sibling document, the Object
or ObjectType that raises the event names it with `tm:ref`; a document that
projects the EventType itself keeps its inline definition and does not reference
itself.
Going **to** a NodeSet, the link is resolved before the EventType's fields and
Condition Methods are synthesized, so an affordance that declares no `data` of
its own materializes the field set the linked definition declares. The clause
list itself projects to no Node — it is a client-side request rather than a
model fact — and the OPC UA binding runtime compiles the resolved selection into
the `EventFilter.SelectClauses` of the MonitoredItem (see
[WotBindings.md](WotBindings.md#event-field-selection-tmref-and-uaveventselectclauses)).
### Resolving a stated selection needs the asynchronous conversion
An EventType definition is a *document*, so deriving a selection means following
a document link. Only `WotNodeSetConverter.ToNodeSetResultAsync` does that, and
only when it is given an `IWotThingResolver` holding the local document context
of Section 5.1.5:
```csharp
WotConversionResult<UANodeSet> result = await WotNodeSetConverter
.ToNodeSetResultAsync(document, options, thingResolver, cancellationToken: ct)
.ConfigureAwait(false);
```
The synchronous `ToNodeSetResult` and `ToNodeSet` follow no link. An affordance
that states a selection — a `tm:ref`, `uav:eventSelectClauses`, or both — is
therefore reported with `WotDiagnosticCode.EventSelectionUnresolved` rather than
converted, because an EventType materialized without the fields its linked
definition declares is indistinguishable from one that never had any. Where
every reference a document writes is local to that document,
`NullWotResolver.Instance` is the explicit "no external resolution" policy that
still takes the asynchronous path. An affordance that states no selection needs
no resolution: it takes the implicit `BaseEventType` default and converts
synchronously as before.
## DataType definitions (Section 6.11)
WoT Binding §6.11 gives Structures, Unions, Enumerations, OptionSets and
SimpleDataTypes a readable vocabulary, so that defining one is no longer on
its own a reason to fall back to the native `uav:nodes` projection. §6.11.8
states that as a completeness contract: a fact the clause covers **shall** be
emitted readably and **shall not** be the reason a converter adds `uav:nodes`.
Both directions are implemented.
**Authoring.** `uav:dataTypeDefinitions` on the Thing root holds the
definitions. Each is a JSON-LD node with an `@id`, a `@type` of
`uav:StructureDefinition`, `uav:EnumDefinition` or `uav:SimpleDataType`, and a
`uav:dataTypeName`. The complete definition occurs in exactly one place and
every other occurrence is an `@id`-only reference; two occurrences that each
contribute properties are rejected rather than merged, because merging two
ordered field lists has no defined answer.
**Identity.** `uav:dataTypeId` states the NodeId where the author wants to.
Otherwise it is derived from `uav:dataTypeName` alone as
`nsu=<NamespaceUri>;s=DataTypes/<Name>`, so the same definition read from a
differently ordered or differently nested document still lands on the same
Node. A field may name a sibling definition by its JSON-LD `@id`; that `@id`
is a graph identifier and is resolved to the sibling's NodeId, never read as
a NodeId itself.
**Inference.** A DataSchema with no explicit definition infers one, but only
where it determines every required fact — inference fails rather than guesses:
| Schema | Result |
|---|---|
| `type: object` with one property | A Structure. |
| `type: object` with more than one property | Requires `uav:fieldOrder`; JSON member order carries no meaning, so without it the encoding order is unknowable and inference **fails**. |
| `required` | Decides `IsOptional`; all required gives `Structure`, otherwise `StructureWithOptionalFields`. |
| `uav:structureType: Union` | A Union. |
| `type: integer` with `oneOf` branches carrying `const` and `uav:enumName` | An Enumeration. |
| `type: integer` with a bare `enum` array | **Not** an Enumeration: it states values but never names them. |
| A bare `integer` or `number` **inside a Structure field** | **Fails.** It is honest about a scalar Variable, where the abstract type permits subtype values, but inside a Structure accepting them would need a subtyped-value kind the schema has not asked for. |
| A custom type named without `uav:dataTypeSubtypeOf` | **Fails.** §6.11.4 forbids a custom type subtyping the abstract `Integer` or `Number`. |
**Encodings.** A non-abstract Structure or Union exposes `Default Binary`,
`Default XML` and `Default JSON`, with identities derived by appending
`/Default Binary` and so on to the type's own identity. An abstract type is
refused encoding identities outright. A concrete type used only inside other
Structures — never directly in an ExtensionObject — may set
`uav:hasDefaultEncoding: false`, and then no encodings are generated for it;
the term is refused on any kind that has no encodings to begin with.
An `OptionSet` states a base of `Byte`, `UInt16`, `UInt32` or `UInt64` wide
enough for its highest authored bit. The abstract `UInteger` is not legal: the
base has to say how many bits exist, and an abstract type says only that there
are some.
**NodeSet to WoT.** Every DataType a NodeSet defines is emitted back into
`uav:dataTypeDefinitions`. Whether a definition is an enumeration is decided
by the shape a NodeSet actually gives it — an enumeration field carries a
value and no DataType, a structure field the reverse — because the file states
no kind directly. The same is true of the structure kind: only `IsUnion` is
recorded, so optionality and subtype allowance are read back off the fields.
An encoding link is searched from both ends, because a NodeSet may write it
from either and real companion models write it from the Object. An alias is
resolved rather than emitted, since a name like `DataType="Structure"` means
nothing outside the document that defines it.
**Known gap.** An inferred definition's own DataSchema terms
(`uav:fieldOrder`, `properties`, `required`, `oneOf`) still travel as residue
rather than being re-derived from the definition, so a document that relies on
inference does not yet reproduce byte-identically and keeps its `uav:nodes`
projection. Closing it is the canonical-schema equivalence of §6.11.6:
derive the schema from the definition, normalize both, and require the two
semantic normal forms to be equal.
## Model and platform vocabulary (Section 6)
The WoT Binding Section 6 model- and platform-vocabulary terms
(composition, containment, naming, semantics, inheritance) and the
anchored browse-path term of Section 5.1.4 are **readable annotations**:
they record OPC UA model facts but have no distinct structure that this
converter materializes into the readable NodeSet (it does not model, for
example, a `HasDictionaryEntry` structure). The converter therefore
handles them in one direction with full round-trip fidelity:
- **WoT to NodeSet.** Each term is validated during synthesis against
the per-term domain and range table of Section 7. A malformed value is
an error (Section 7 requires a consumer to treat the document as
invalid rather than repair it), so `ToNodeSet` throws and
`ToNodeSetResult` reports the diagnostic. A well-formed value is
carried unchanged through the generic `uav:nodes` residue mechanism.
- **NodeSet to WoT (reverse).** These readable terms are not synthesized
from a plain NodeSet. When a NodeSet carries a structure that the
readable vocabulary cannot yet express, the complete `uav:nodes`
native projection preserves it losslessly, and any residue previously
captured for a term is re-applied by JSON Pointer.
- **Round-trip.** A document carrying these terms survives
WoT → NodeSet → WoT unchanged. Affordance-level terms
(`uav:scaleFactor`, `uav:decimalPlaces`, `uav:semanticId`) are
preserved under the affordance's projected local name, so an affordance
that also carries `uav:browseName` round-trips under that browse name's
local part rather than its original map key.
The opaque terms `uav:metadata`, `uav:propertyConfiguration`,
`uav:actionConfiguration` and `uav:eventConfiguration` are never read and
never cause rejection; they are carried verbatim. Their **shape** is
checked, because a consumer that must carry a value unchanged and must
not reject it is otherwise obliged to carry an unbounded, unattributable
value (Section 6.6): every top-level key is an absolute IRI or a compact
IRI whose prefix the document's `@context` binds, and the object stays
within 65 536 octets in the **compact received form** of Annex G.4 (see
[Preservation digests and the two things that can be measured](#preservation-digests-and-the-two-things-that-can-be-measured)),
32 levels of nesting and 256 top-level keys. Revision 1.0 stated no key
rule, so a document whose keys are not namespaced is **preserved** and
reported as deprecated rather than rejected; strict conformance (below)
turns the same finding into an error.
## Conformance claims and strict mode (Sections 4.1, 6.1, 6.6 and 11)
Four document-level terms describe the document rather than the
AddressSpace, so none of them becomes a Node and all of them survive a
round trip verbatim through the residue mechanism:
| Term | Where | What is checked |
|---|---|---|
| `uav:bindingVersion` | TD/TM root | a `<major>.<minor>` revision string (Section 4.1) |
| `uav:profile` | TD/TM root | a non-empty array of `WoT-<name>` conformance claims |
| `uav:eventSelectClauses` | event affordance | a non-empty ordered list of clauses carrying exactly `tm:ref` and a relative `uav:browsePath`, with no two clauses of the final selection materializing the same `data` member path (Section 6.1) |
| `tm:ref` | event affordance and select clause | a document URI with an optional RFC 6901 JSON Pointer resolving, in the local document set, to an EventType definition that carries `uav:eventType`, a portable `uav:id`, an object `data` schema and no selection of its own (Section 6.1) |
| `uav:minimumSecurity` | `auto` security scheme | `uav:securityMode`, `uav:securityPolicy`, or both, and no other member (Section 5.7.1) |
### Authoring a claim and processing one are different acts
Section 4.1 states the two checks side by side and makes them deliberately
different, and `WotNodeSetConverterOptions.AuthoringValidation` selects
between them:
| | Consumer (the default) | `AuthoringValidation = true` |
|---|---|---|
| `uav:bindingVersion` | **shall** be `<major>.<minor>`; a well-formed value this library does not implement is a `UnsupportedBindingRevision` **warning** and is preserved | **shall** name a revision this Binding publishes (`1.0` or `1.1`); anything else is an **error** |
| `uav:profile` | **shall** be a non-empty array of `WoT-<name>` strings; an entry Section 11 does not define is an `UnrecognizedConformanceClaim` **warning** and is preserved | **shall** name only units and profiles Section 11 defines |
| `unit` carrying a quantity kind | a `QuantityKindInUnit` **warning**; the authored value is preserved and never reinterpreted | an **error** (Section 6.4) |
A consumer that rejected a document for naming a revision or a unit it
does not know would be refusing to read a document that is syntactically
valid, whose known terms it understands, and whose unknown terms it is
already required to carry — which is the failure that makes a vocabulary
unextendable. A malformed claim stays an error in every mode: the
syntactic rule is what a consumer enforces, and it enforces it always.
Both published revisions are read here. `WotBindingConformance.SupportedRevisions`
names `1.0` and `1.1`, and the two places revision 1.0 differs — unnamespaced
opaque keys (Section 6.6) and a quantity kind in `unit` (Section 6.4) — are
reported as deprecated and preserved rather than rejected.
`Opc.Ua.Wot.WotUnitMigration.MoveQuantityKinds` is the opt-in helper that moves
such a value to `qudt:hasQuantityKind`; it never invents an engineering unit in
the vacated member, because none is recoverable from a quantity kind, and it
leaves a value alone where the affordance already states a *different* quantity
kind.
`WotNodeSetConverterOptions.ConformanceMode` chooses how strictly the rest
is held:
- **`Permissive`** (the default) processes what it understands and
preserves the rest. An unknown `uav:` term is carried unchanged as
residue rather than reported, and an unnamespaced opaque key is a
warning. This is what Sections 4.1, 6.6, 9.4 and 10.2 require of a
consumer.
- **`Strict`** additionally reports a `uav:` term this revision does not
define, an opaque object that breaks the key or bound rules, and —
where `RequiredConformance` names units or profiles — a claim the
document does not make. Claiming a profile claims every unit it names,
so `WoT-Modeller` satisfies a requirement of `WoT-EventMapping`.
Strict mode is for authoring and conformance testing: a misspelled term
should fail there rather than travel silently. It is never the default,
because a consumer is not allowed to reject a document for carrying a
term it does not know.
The revision this library implements, the Section 11 name set, the
opaque bounds and the security strength orders are stated once, in
`Opc.Ua.Wot.WotBindingConformance`; the select-clause term, its shape rules,
its member-naming rule and its documented `BaseEventType` default are stated
once, in `Opc.Ua.Wot.WotEventSelectClauses`. `WotBindingConformance.VocabularyTerms`
is the complete set of 113 `uav:` IRIs the published `@context` mints:
`IsKnownTerm` answers for the 100 a document spells with the prefix, and
`ScopedTerms` / `IsScopedTerm` for the 13 a scoped context mints under a short
member name (`namespaceUri`, `unitId`, `minimum`, `sha256` and the rest).
### The `uav:nodes` record grammar is not vocabulary
The member names inside `uav:nodes` — `nodeClass`, `nodeId`, `browseName`,
`references` and the rest — are the lower-camel-case spellings of the UANodeSet
XSD field names. They expand in the record-grammar namespace
`WotBindingConformance.NodesVocabularyNamespace`
(`http://opcfoundation.org/UA/WoT-Binding/nodes/`) rather than in the term
namespace, because they are a versioned record format and not vocabulary that
outlives it. Keeping them apart is also what stops a grammar token from
colliding with a class annotation of the same spelling: a node record's
`dataType` member holds the ExpandedNodeId of a Variable's DataType and is not
the NodeClass annotation `uav:dataType`, and a reference record's
`referenceType` member is not `uav:referenceType`. Strict conformance skips both
structured projections whole, so a record member name is never reported as an
unknown term.
### Generated documents state the revision they were generated against
Section 4.1 requires a *generator* to state `uav:bindingVersion`, because a
generator — unlike a hand author — always knows which revision it emitted. Every
document `FromNodeSet` produces therefore carries
`uav:bindingVersion: "1.1"`. An authored claim wins over that stamp: a document
that declared `1.9` round-trips as `1.9`, because a claim a consumer shall
preserve is not one it may overwrite. A claim that agrees with the stamp is
re-derived rather than also carried as residue.
### NodeClass and ReferenceType vocabulary
The NodeClass annotations and ReferenceType Attributes are:
| Term | Where | What it says |
|---|---|---|
| `uav:referenceType` | `@type` at TM root | the document projects a ReferenceType Node (Sections 5.2 and 6.2.1) |
| `uav:dataType` | `@type` at TM root | the document projects a DataType Node, whose definition travels in `uav:dataTypeDefinitions` |
| `uav:inverseName` | TM root with `uav:referenceType` | the OPC 10000-3 `InverseName`, in the document's default locale |
| `uav:symmetric` | TM root with `uav:referenceType` | the OPC 10000-3 `Symmetric` flag, `false` where absent |
A `@type` carries **at most one** NodeClass annotation — a Node has exactly one
NodeClass — and a second is a `NodeClassAnnotationConflict` error. A document
that sets `uav:symmetric: true` **shall not** carry `uav:inverseName`, because a
symmetric Reference reads the same in both directions; that, and either term on
a document that projects no ReferenceType, is a
`ReferenceTypeProjectionInvalid` error.
WoT Binding 1.1 defines neither `uav:severity` nor `uav:isEvent`. Event identity
comes from the `@type: uav:eventType` annotation alone, and `Severity` is a field
of an occurrence carried by the notification data schema rather than affordance
metadata. Generated documents emit neither member, nothing is synthesized from
either, and permissive conversion preserves either unknown member as ordinary
residue. Strict authoring reports either as an unknown term.
## Engineering units, ranges and scaling (Sections 6.4 and 6.4.1)
`unit`, `uav:unitProperty`, `uav:engineeringUnits`, `minimum`/`maximum` and
`uav:instrumentRange` are **mapped**, not carried: each names a Property Node
of an `AnalogUnitType` or `AnalogItemType` Variable, and both directions
materialize it.
Five of the `uav:` terms — `uav:engineeringUnits`, `uav:unitProperty`,
`uav:instrumentRange`, `uav:scaleFactor` and `uav:decimalPlaces` — describe the
Variable a **property affordance** projects, and are rejected anywhere else. The
role is decided once, at the document root: the members of the root `properties`
map are property affordances, and everything below one — a member of an action's
`input`, of an event's `data`, or of a property affordance's own DataSchema —
rejects all five. A field of a payload is not a Variable, so it carries no
`EngineeringUnits` Property for a unit to describe, and a `properties` map deeper
in a document is a DataSchema member map rather than an affordance map however it
is spelled.
**NodeSet to WoT.** For every Variable that holds them, the converter decodes
the three OPC 10000-8 Properties:
- `EngineeringUnits` projects to a property affordance of its own. That
affordance carries `type: "string"` — what a client reads there at run time —
the definitive `uav:mapToType` `i=887`, and `uav:engineeringUnits` with the
`EUInformation`'s `namespaceUri`, integer `unitId`, `displayName` and
`description`. The authority and its machine-readable code are what a display
string alone cannot recover.
- The annotated Variable's affordance states `unit` (the `EUInformation`
`DisplayName`, never a quantity kind) and `uav:unitProperty`, a canonical
`/properties/<name>` pointer at that sibling affordance.
- `EURange` becomes `minimum` and `maximum`; `InstrumentRange` becomes
`uav:instrumentRange`.
Only a Property whose value actually decodes contributes. A foreign encoding, a
partial structure or a missing value leaves the Property an ordinary affordance,
the readable mapping is then incomplete, and the `uav:nodes` projection carries
it — a reported gap rather than a number nobody wrote. Nothing is ever derived
from the width of a DataType: an `Int16` reads from −32768 to 32767, and that is
a fact about the machine representation rather than an engineering range.
**WoT to NodeSet.** `uav:engineeringUnits` materializes the `EUInformation`
value; `uav:unitProperty` makes the affordance it names a `HasProperty` child of
the annotated Variable, unless that affordance states its own `uav:componentOf`;
`minimum`/`maximum` and `uav:instrumentRange` materialize `EURange` and
`InstrumentRange` Properties (`Range` `i=884`, `PropertyType`), or fill in the
value of the Node an authored `EURange`/`InstrumentRange` affordance already
produced.
`uav:scaleFactor` and `uav:decimalPlaces` stay readable annotations carried
through residue. Section 6.4 describes them as a static presentation and
transport transform, so a converter neither derives them from the analog
Properties nor derives those Properties from them; a source NodeSet that states
neither never gains either.
## Localized text (Section 9.1.1)
`DisplayName` and `Description` are `LocalizedText`, and both directions carry
every locale.
**NodeSet to WoT.** The document's default locale is the locale the root Node's
own `DisplayName` (or `Description`) states, declared as the `@language` of the
generated `@context`; a source that names none declares none, and Section
9.1.1's `en` then applies. A Node with one locale writes `title` and
`description` alone. A Node with several writes the plural `titles` and
`descriptions` maps as well. The plural member is **authoritative**: it carries
every locale the source had, and the singular member is the default projection a
consumer that knows nothing of the plural members reads. Where the plural member
has an entry for the document's default locale the singular member is that
entry, so the two never disagree. Where it has **no** such entry — a Node
authored in the plant's language and read against another default locale — the
plural member is still written in full and the singular member carries the entry
whose BCP 47 tag is first in ascending Unicode code-point order (Annex G.3), as
a display fallback that asserts no locale. The document does not claim that text
is written in the default locale, and nothing is pushed into the exceptional
`uav:nodes` projection to say something the plural member already says.
Asserting no locale is a claim a JSON-LD reader has to be told about. `title` and
`description` are terms of the W3C Thing Description context, and a `@context`
that declares `@language` tags every unqualified value with it - so a German
singular member would expand as English text. Where any projected text states no
entry for the document's default locale, the generated `@context` therefore
carries **one** further entry re-declaring the two terms with `"@language": null`.
It is written only where the document needs it: adding it unconditionally would
strip the language tag from every document this library writes. Being derived
from the projected Nodes it is re-derivable and is not also captured as residue;
an author's own override of the same terms says something different and is kept.
The same problem has a different answer inside `uav:engineeringUnits`. Section
6.4.1 mints `displayName` and `description` there as **short members under a
type-scoped context**, so a root-level override cannot reach them: the scoped
context is entered on that object and nowhere else. Where the EUInformation's
text is not in the document's default locale, the object therefore carries its
own node-local `@context` re-declaring `displayName` as `uav:unitDisplayName`
and `description` as `uav:unitDescription`, each with `"@language": null`.
`namespaceUri` and `unitId` are short members of that same scoped context, which
is why the generated document names the Binding context itself
(`http://opcfoundation.org/UA/WoT-Binding/v1.1/opc-ua-wot-binding.context.jsonld`)
alongside the W3C one: a short member is a term only while the context defining
it is in scope, and a document that named only the `uav` prefix would expand
those members to nothing. The identity is version-pinned because a document
states which revision it was written against, and a context that moves under a
document is a document whose meaning changed without it being edited.
**WoT to NodeSet.** A plural member becomes one `LocalizedText` per entry. The
entry written first — the one the Node's own attribute carries — is the
default-locale entry where the map has one and the code-point-first entry
otherwise, which is the same entry the singular member carries, so the round
trip is stable. A singular member alone becomes one `LocalizedText` tagged with
the document's declared `@language`, or untagged where the context declares none
— the form a UANodeSet writes when it names one language without saying which.
The same selection is used wherever a term reduces a `LocalizedText` to one
string: a ReferenceType's `uav:inverseName`, and the `displayName` and
`description` of `uav:engineeringUnits`. The `unit` member of the annotated
Variable is taken in that same locale, so a multi-locale `EUInformation` states
one text in both places instead of falling back to preservation because the two
disagree.
The mapping applies to the root, to property, action and event affordances, to
event fields, to `Method` argument descriptions, and to DataType definitions and
their structure and enumeration fields. A field's `DisplayName` maps to `title`.
## ValueRank and ArrayDimensions (Sections 7 and 9.1)
A DataSchema's `type` says whether a value is an array, not which of the five
things an OPC 10000-3 ValueRank says. `uav:valueRank` and `uav:arrayDimensions`
are therefore emitted and read on ordinary Variable affordances as well as on
`Method` arguments, event fields and DataType-definition fields.
The scalar rank `-1` is the default a NodeSet omits, so it is not restated;
`-3` (ScalarOrOneDimension), `-2` (Any), `0` (OneOrMoreDimensions) and every
fixed positive rank are written and read back exactly, and none of them is
collapsed to a scalar. `uav:arrayDimensions` carries one bound per dimension —
`0` for a dimension whose length is not fixed — so its length is the rank by
construction: a count that disagrees with a fixed rank, or any dimension against
a rank that fixes none, is an `InvalidValueRank` error rather than a silently
malformed Variable.
## Method arguments (Section 9.1)
A UA Method's `InputArguments` and `OutputArguments` are the WoT action's
`input` and `output` DataSchemas, in both directions.
**NodeSet to WoT.** The `Argument` structures the argument Properties hold are
decoded into an object DataSchema whose members are the arguments, whose
`uav:fieldOrder` states their declaration order — the order an OPC 10000-4
`Call` is positional over — and whose `required` lists all of them, because a
Call supplies all of them. Each member carries the WoT type members that stand for its DataType, the
definitive `uav:mapToType`, `uav:valueRank`, any `uav:arrayDimensions` and its
`Description`. An argument Property represented by the schemas is not also
emitted as a sibling property of the Thing. A Property whose value cannot be
decoded remains a sibling so no Node is lost. The readable schemas state what
the arguments are and not which Nodes hold them, so the exact NodeId and
attributes of the argument Properties travel in the `uav:nodes` preservation
projection.
**WoT to NodeSet.** See the `input` / `output` row of the defaults table above.
## ReferenceTypes and relations (Sections 5.1.2, 5.3 and 6.2)
The readable mapping carries some References structurally — containment as
affordances and `uav:hasComponent` / `uav:componentOf`, the type hierarchy as
`tm:extends`, the type definition as a `ua:HasTypeDefinition` link, the
modelling rule as `uav:modellingRule`, the event source as an event affordance
and a DataType's encodings as `uav:defaultEncodingId`. Section 6.2 says a
Reference is a single relation and a document shall not be read as declaring
two, so none of those is written a second time.
**NodeSet to WoT.** Every *other* Reference — a companion model's own
ReferenceType, `ua:HasInterface`, `ua:Organizes` — is written as a typed link:
the ReferenceType's compact model name in `rel`, its portable
`ExpandedNodeId` in `uav:refId`, and the target's own BrowseName in
`uav:refName` where the NodeSet holds the target. A reference that runs inverse
is written under the ReferenceType's **InverseName**, which is what states the
direction. The compact name comes from the NodeSet itself, in order of
specificity: its own `UAReferenceType` declarations (the only place an
InverseName and the Symmetric flag are stated), its `<Aliases>` table, then the
standard base-namespace names. A ReferenceType none of the three names, and the
inverse direction of one that declares no InverseName, are reported
(`ModelConceptUnresolved`) rather than written under a substitute relation: a
link whose `rel` named a different ReferenceType, or whose direction was
silently reversed, would read as a fact the source never stated.
A document that projects a ReferenceType Node itself additionally carries
`uav:inverseName` and `uav:symmetric`, so a set of documents describes a
companion model's relations completely enough to convert back.
**WoT to NodeSet.** See the ReferenceType rows of the defaults table above, and
[Resolving a relation: companion ReferenceTypes](WotBindings.md#resolving-a-relation-companion-referencetypes)
for how a `rel` and a `uav:refId` are resolved against the Section 5.1.5 local
context.
### `uav:componentOf` and its `ua:ComponentOf` alias
Section 9.1 spells the parent-placement relation `uav:componentOf` and declares `ua:ComponentOf` as an alias of it. Both
spellings are accepted wherever the relation is read, and they are treated as
the same term rather than as a term and a lookalike: the alias reads as a
compact model name whose local part is the InverseName of `HasComponent`, so it
is intercepted as a binding term instead of being realized a second time as a
generic inverse typed link.
This implementation writes `uav:componentOf` and accepts but does not emit the
alias, keeping one relation in one spelling on output.
### Projection documents and the OPC 10100-1 v1.02 asset surface
Two neighbouring subjects are documented elsewhere so they are stated once:
- A **projection document** declares affordances instead of defining them, and
Section 12.5 closes the set of members it may annotate beside `tm:ref`. See
[Projection documents and the View NodeClass](WoTConnectivity.md#124-projection-documents-and-the-view-nodeclass).
- The **OPC 10100-1 v1.02 asset surface** (`CreateAsset`, `WoTFile` upload, the
`AssetRegistry` POCO reader) is a separate code path governed by that
specification. Nothing on this page describes it. See
[The 1.02 asset surface](WoTConnectivity.md#126-the-102-asset-surface).