OPC UA WoT Connectivity (OPC 10100-1)
June 28, 2026 · View on GitHub
This repository implements the OPC UA WoT Connectivity companion specification (OPC 10100-1, "WoT Connectivity for OPC UA") through three class libraries plus an integration test project:
| Project | Purpose |
|---|---|
Opc.Ua.WotCon | Source-generated information model (NodeStates, NodeIds, generated ObjectType client proxies) compiled from the official WotConnection.xml design + WotConnection.csv |
Opc.Ua.WotCon.Server | Server-side node manager (WotConnectivityNodeManager → AsyncCustomNodeManager) and the extensible provider model |
Opc.Ua.WotCon.Client | Client wrappers + extension methods that compose the generated proxies without inheritance |
Opc.Ua.WotCon.Tests | NUnit tests covering the TD parser, mappers, simulated provider, discovery facade |
The model namespace URI is http://opcfoundation.org/UA/WoT-Con/,
target version 1.02.0, publication 2025-12-05.
1. Hosting a WoT Connectivity server
The node manager is exposed through WotConnectivityNodeManagerFactory,
which plugs into a StandardServer via the standard
AdditionalNodeManagers mechanism. A typical setup:
var options = new WotConnectivityServerOptions
{
ThingDescriptionStorageFolder = Path.Combine(AppContext.BaseDirectory, "wot-assets")
};
options.Bindings.Add(new MyHttpWotAssetProviderFactory());
options.Bindings.Add(new MyModbusWotAssetProviderFactory());
options.Discovery = new MyDiscoveryProvider(); // optional
server.NodeManagerFactories.Add(new WotConnectivityNodeManagerFactory(options));
The factory advertises two namespaces:
http://opcfoundation.org/UA/WoT-Con/— the static model (loaded through the source-generator'sAddOpcUaWotConextension).http://opcfoundation.org/UA/WoT-Con/Assets/(default) — the dynamic namespace where assets, property variables, and action methods land. Override withWotConnectivityServerOptions.AssetNamespaceUri.
WoTAssetConnectionManagement is automatically organized below
Objects. On first call to LoadPredefinedNodes, the server wires the
spec's six methods (CreateAsset, DeleteAsset, optionally DiscoverAssets,
CreateAssetForEndpoint, ConnectionTest, plus the configuration object).
Any persisted TDs in the storage folder are re-materialised on startup.
Lifecycle
- Client calls
CreateAsset(name)→ server creates anIWoTAssetTypeinstance (HasInterfacereference) with a singleWoTFilechild. - Client opens
WoTFilewith modeWrite|EraseExisting(the only write mode allowed per Spec §6.3.10), writes a JSON TD, and callsCloseAndUpdate. - Server parses the TD, selects a registered
IWotAssetProviderFactorywhoseCanHandleaccepts it, connects the resulting provider, and materialises a property variable for each WoT property (mapped per Table 14) and a method node for each WoT action (mapped per §6.3.9).
Optional flow when DiscoverAssets / CreateAssetForEndpoint /
ConnectionTest are wired:
DiscoverAssetsreturns a list of asset endpoints.ConnectionTestverifies one of them.CreateAssetForEndpoint(name, endpoint)synthesises a TD viaIWotAssetDiscoveryProvider.CreateThingDescriptionAsyncand runs the same materialisation path — no client upload needed.
2. Writing a custom IWotAssetProvider
A provider drives a single asset's data plane. The interface is deliberately small so a binding driver only owns the parts that change between protocols:
public sealed class MyHttpWotAssetProvider : IWotAssetProvider
{
public ValueTask<(ServiceResult, object?)> ReadAsync(WotPropertyTag tag, CancellationToken ct);
public ValueTask<ServiceResult> WriteAsync(WotPropertyTag tag, object? value, CancellationToken ct);
public ValueTask SubscribeAsync(WotPropertyTag tag, uint id, OnWotValueChange cb, CancellationToken ct);
public ValueTask UnsubscribeAsync(WotPropertyTag tag, uint id, CancellationToken ct);
public ValueTask<ServiceResult> InvokeActionAsync(WotActionTag action, IReadOnlyList<object?> inputs, IList<object?> outputs, CancellationToken ct);
public ValueTask DisposeAsync();
}
Pair it with an IWotAssetProviderFactory that advertises the WoT
binding URIs it understands (surfaced through
SupportedWoTBindings per Spec §6.3.1.1):
public sealed class MyHttpWotAssetProviderFactory : IWotAssetProviderFactory
{
public IReadOnlyCollection<string> SupportedBindings { get; }
= new[] { "https://www.w3.org/2019/wot/http" };
public bool CanHandle(ThingDescription td) =>
td?.Base?.StartsWith("http://", StringComparison.OrdinalIgnoreCase) == true ||
td?.Base?.StartsWith("https://", StringComparison.OrdinalIgnoreCase) == true;
public ValueTask<IWotAssetProvider> ConnectAsync(ThingDescription td, CancellationToken ct)
=> new(new MyHttpWotAssetProvider(td));
}
Each WoT property's binding-specific forms element is passed through
on the WotPropertyTag.Form (raw JsonElement); providers parse it
into whatever protocol metadata they need.
For Discover / CreateForEndpoint / ConnectionTest, register an
IWotAssetDiscoveryProvider on WotConnectivityServerOptions.Discovery.
Any individual method may throw NotSupportedException — the node
manager translates that into BadNotSupported.
The repository ships with a canonical SimulatedWotAssetProvider in
the test project. It is a complete, working example of the contract
(read / write / observe / action echo) and serves as the default
provider for the test suite.
3. Using the client
WotConnectivityClient composes the generated
WoTAssetConnectionManagementTypeClient and adds asset enumeration,
NodeId resolution, and WotAssetClient construction:
WotConnectivityClient client = await WotConnectivityClient.ForServerAsync(
session, session.MessageContext.Telemetry, ct);
WotAssetClient asset = await client.CreateAssetAsync("PressureSensor01", ct);
await asset.UploadThingDescriptionAsync(File.ReadAllBytes("sensor.td.jsonld"), ct);
await foreach (WotAssetVariableEntry property in asset.EnumeratePropertiesAsync(ct))
{
DataValue value = (await session.ReadValueAsync(property.NodeId, ct))!;
Console.WriteLine($"{property.BrowseName} = {value.WrappedValue}");
}
await client.DeleteAssetAsync(asset.AssetId, ct);
FileSystem extensions
The client does not subclass any of the existing
Opc.Ua.Client.FileSystem types. Instead it ships extension methods on
the generated FileTypeClient / WoTAssetFileTypeClient proxies that
add what the spec needs but the base FileSystem client cannot offer
(CloseAndUpdate exists only on WoTAssetFileType):
FileTypeClient.UploadAsync(bytes, …)— chunked write with automaticOpen(Write|EraseExisting)→Write*→Close.FileTypeClient.UploadAsync(Stream, …)— same flow but reads the content from aSystem.IO.Streamso callers don't have to buffer the entire payload in memory. Non-seekable streams (NetworkStream,GZipStream, …) are supported.FileTypeClient.DownloadAllAsync(…)— chunked read until end-of-file.FileTypeClient.DownloadToAsync(Stream, …)— chunked read that writes each chunk directly to the suppliedSystem.IO.Stream.WoTAssetFileTypeClient.UploadAndUpdateAsync(td, …)— uploads the TD (asReadOnlyMemory<byte>orSystem.IO.Stream) and then callsCloseAndUpdate(Spec §6.3.10).
WotAssetClient exposes the same upload / download convenience pair —
UploadThingDescriptionAsync and DownloadThingDescriptionAsync —
both with a ReadOnlyMemory<byte> / byte[] overload and a
System.IO.Stream overload, e.g.:
await using FileStream tdFile = File.OpenRead("device.td.json");
await asset.UploadThingDescriptionAsync(tdFile, ct);
Stream-based callers retain ownership of the stream — the WoT Connectivity client never disposes the caller's stream.
These work on any FileType instance, including ones that are not
anchored under Server.FileSystem (e.g. the WoT asset file living
under WoTAssetConnectionManagement/<asset>).
Method invocation and server interoperability
The generated …TypeClient proxies invoke methods through the shared ObjectTypeClient.CallMethodAsync helper using the type-declaration MethodId (the Method node on the ObjectType). This is fully spec-conformant: OPC UA Part 4 §5.12.2.2 (v1.04 §5.11.2.2) states that, for a Call on an Object instance, the methodId may be either the instance Method's NodeId or the NodeId of the Method on the ObjectType that defines it. This stack's own server accepts both forms.
A few non-conformant servers only bind the method handler on the instance and reject the type-declaration MethodId with Bad_MethodInvalid. To interoperate with those servers, CallMethodAsync transparently falls back: on Bad_MethodInvalid it resolves the instance MethodId via a HasComponent browse path (TranslateBrowsePathsToNodeIds), caches it on the proxy, and retries the call once. Conformant servers never trigger the fallback and therefore pay no extra round-trip; subsequent calls against a non-conformant server reuse the cached instance MethodId.
4. Persistence limits
The persisted-TD loader (AssetRegistry.EnumeratePersistedAsync) walks
the configured ThingDescriptionStorageFolder and re-materialises every
*.jsonld file at startup. The following options bound the work and
the per-file resources so a corrupted or adversarial persistence
directory cannot wedge startup through CPU/memory/stack exhaustion:
| Option | Default | Effect |
|---|---|---|
MaxThingDescriptionSize | 1 MiB | Per-file size cap. Files larger than this are skipped at load time with a warning that names the file and reports the size. Also enforced on the write path via the OPC UA file primitives. |
MaxPersistedThingDescriptionFiles | 10 000 | Hard cap on the number of *.jsonld files processed per startup. When reached, the loader emits a single warning and stops; the server still comes up with the assets that were loaded. Set to 0 (or negative) to disable persistence loading entirely without removing the directory. |
MaxThingDescriptionJsonDepth | 64 | Maximum JSON nesting depth honoured by the JsonSerializer.MaxDepth bound. Comfortably accommodates standard W3C Thing Descriptions while staying well below the default .NET recursion budget. Files that exceed the depth are skipped with a warning (the loader does not throw). |
Bumping the defaults is appropriate for controlled environments that have audited the source of the persisted files; for example:
var options = new WotConnectivityServerOptions
{
ThingDescriptionStorageFolder = "/var/lib/myapp/wot",
MaxThingDescriptionSize = 4 * 1024 * 1024, // 4 MiB
MaxPersistedThingDescriptionFiles = 50_000, // ~50k assets
MaxThingDescriptionJsonDepth = 128 // headroom for deeper TDs
};
OperationCanceledException is propagated unmodified — cancelling the
startup token cancels the enumeration without losing the cancellation
type. JsonException and IOException are caught and surfaced as
per-file warnings; no other exception type is silently swallowed.
5. Name validation
Two validators harden the path from third-party input to address-space nodes:
WotAssetNameValidator(asset names fromCreateAsset/CreateAssetForEndpoint) — rejects names that would escape the persistence folder, contain NUL bytes, hit a Windows reserved device name (CON,PRN,AUX,NUL,COM1..9,LPT1..9), start with.,, or~, or end with.or.WotChildNameValidator(TDproperties/actionskeys) — rejects names that would corrupt the OPC UA address space or enable visual-spoofing in a browse viewer:- empty / whitespace-only /
> 128chars, - leading or trailing whitespace,
- any
char.IsControlor BIDI / format character (LRM, RLM, LRE, RLE, PDF, LRO, RLO, LRI, RLI, FSI, PDI — see Unicode TR9 §2.1), - any of
/,\,.,#,:,!— characters that have syntactic meaning inNodeId/ browse-path expressions or that re-interpret to a path separator at the file-system layer.
- empty / whitespace-only /
Invalid names produce a single LogWarning (with the offending name
passed through WotChildNameValidator.SanitiseForLog so a hostile
name cannot reshape the rendered log line) and are skipped — the
remaining valid children still materialise so one bad TD entry does
not poison the whole asset.
Duplicate child names (case-sensitive) are also rejected after validation: only the first occurrence wins, the rest are logged as duplicates.
6. Endpoint policy
CreateAssetForEndpoint and ConnectionTest accept an endpoint URI
from a remote OPC UA client. Before that string flows into the
discovery provider, it passes through AssetEndpointValidator against
the configured WotConnectivityServerOptions.AssetEndpointPolicy.
Safe defaults:
AllowedSchemes={ http, https, opc.tcp }— anything else (file:,gopher:,javascript:, custom OS-vendor schemes, …) returnsBad_SecurityChecksFailed.AllowLoopback = false— blocks127.0.0.0/8,::1, and the literal host nameslocalhost,ip6-localhost,ip6-loopback.AllowPrivateAddresses = false— blocks RFC1918 (10/8, 172.16/12, 192.168/16), IPv4 link-local (169.254/16 — including the AWS / Azure IMDS address169.254.169.254), IPv6 ULA (fc00::/7), and IPv6 link-local (fe80::/10).AllowedHosts(empty) andBlockedHosts(empty) — optional exclusive allow-list and always-deny list of host names.MaxOperationTimeout = 30 s— wraps every provider call with a linkedCancellationTokenSource.CancelAfter; on expiry the call returnsBad_Timeouteven when the upstream provider hangs.
Opening up a single internal device while keeping the global block-list:
var options = new WotConnectivityServerOptions
{
AssetEndpointPolicy = new AssetEndpointPolicy
{
// Default safe scheme list; add a private-network device
// explicitly via AllowedHosts.
AllowPrivateAddresses = false
}
};
options.AssetEndpointPolicy.AllowedHosts.Add("10.20.30.40");
Security note. The validator does NOT resolve DNS. Resolving a
host name to an IP at validation time and then re-resolving it at
connect time is itself a TOCTOU SSRF vector — a hostile DNS could
return a public IP to the validator and a private IP to the
connector. Operators who need IP-range enforcement must either pin
AllowedHosts to IP literals or accept that the IP-range gates only
fire when the host portion of the URI itself is an IP literal.
7. Error reporting
AssetRegistry never propagates the raw Exception.Message /
StackTrace / GetType().Name from a discovery or provider call to
the remote OPC UA client. The returned ServiceResult carries only a
mapped StatusCode and a generic operation name (e.g. "DiscoverAssets failed.", "ConnectionTest failed.", "Asset property read failed.").
The full exception detail — including the inner ex.Message, the
stack trace, and the asset / endpoint context — is logged via
ITelemetryContext-derived m_logger at LogError (for control-plane
operations) or LogWarning (for per-property / per-action data-plane
operations).
Exception → StatusCode mapping:
| Exception type | Status |
|---|---|
NotSupportedException | Bad_NotSupported |
ArgumentException | Bad_InvalidArgument |
IOException | Bad_ResourceUnavailable |
| any other | Bad_InternalError (control plane) / Bad_CommunicationError (data plane) |
OperationCanceledException | rethrown unchanged — never mapped to a status code |
Internal endpoint URIs, file-system paths, provider implementation details, and stack-trace fragments therefore never leak across the OPC UA wire. Operators retain the full diagnostic detail through the server log.
8. Security: management access policy
The five management methods on the standard
WoTAssetConnectionManagement object — CreateAsset, DeleteAsset,
DiscoverAssets, CreateAssetForEndpoint, ConnectionTest — mutate
the asset registry and trigger outbound network activity. Anonymous,
unauthenticated callers must not be able to reach them.
The node manager therefore enforces a
WotManagementAccessPolicy as the very first action of every method
handler. Defaults:
| Knob | Default | Rationale |
|---|---|---|
MinimumSecurityMode | SignAndEncrypt | Confidentiality + integrity required. |
AllowAnonymous | false | Anonymous identity rejected even on encrypted channels. |
RequiredRoleId | WellKnownRole_SecurityAdmin | Mirrors Opc.Ua.Server.ConfigurationNodeManager for the equivalent ServerConfiguration methods. |
On denial the handler logs a warning (with operation, token type and
granted-role list) and throws
ServiceResultException(BadUserAccessDenied). Internal callers that
invoke the underlying AssetRegistry APIs directly — startup
restoration, persisted-asset replay, in-process tests — flow an
OperationContext-less SystemContext; the policy check is skipped
in that path so server bootstrap continues to work.
Override the policy via DI:
services.AddOpcUa()
.AddServer(...)
.AddWotConServer(opts =>
{
opts.ManagementAccess = new WotManagementAccessPolicy
{
RequiredRoleId = ObjectIds.WellKnownRole_ConfigureAdmin,
MinimumSecurityMode = MessageSecurityMode.SignAndEncrypt,
AllowAnonymous = false
};
});
To loosen the policy (for example a closed lab deployment where the
client cannot present a non-anonymous identity), set
AllowAnonymous = true and grant the anonymous identity the chosen
role via your role-mapping layer; do not weaken MinimumSecurityMode
in production.
9. Limitations and known issues
- WoT action input/output mapping handles the flat
type:objectshape illustrated by Spec §6.3.9 (apropertiesbag with scalar / array members). Deeper schemas — nested objects, oneOf, items-of-object — are collapsed to a singleBaseDataTypeargument with the JSON schema preserved in the description. - Property mapping follows Spec Table 14:
number → Double,integer → Int64,boolean → Boolean,string → String. Properties withtype: objectortype: null(or notypeat all) are materialised with statusBadConfigurationErroron read (per Spec §6.3.8 last paragraph). WoTAssetFileType.Openrejects modes other thanRead (1)andWrite | EraseExisting (6)withBadNotSupported, matching the spec text.
10. References
- OPC 10100-1, WoT Connectivity for OPC UA: https://reference.opcfoundation.org/specs/OPC-10100-1/full
- W3C Web of Things Thing Description 1.1: https://www.w3.org/TR/wot-thing-description11/
- W3C WoT Binding Templates: https://w3c.github.io/wot-binding-templates/