diff --git a/UA.slnx b/UA.slnx
index 1a4f94be9d..256ae2fa3d 100644
--- a/UA.slnx
+++ b/UA.slnx
@@ -106,6 +106,7 @@
+
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs
new file mode 100644
index 0000000000..cbb234e9e8
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingChannel.cs
@@ -0,0 +1,386 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Buffers;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using MQTTnet;
+using MQTTnet.Exceptions;
+using MQTTnet.Protocol;
+
+namespace Opc.Ua.WotCon.Bindings.Mqtt
+{
+ ///
+ /// A live MQTT binding channel that publishes writes / actions and subscribes
+ /// for reads / observes / events per the pinned MQTT binding, with bounded QoS,
+ /// payload sizes and read timeouts.
+ ///
+ internal sealed class MqttWotBindingChannel : IWotBindingChannel
+ {
+ public MqttWotBindingChannel(
+ IMqttClient client,
+ WotCompiledForm form,
+ WotExecutorContext context,
+ MqttWotBindingOptions options)
+ {
+ m_client = client;
+ Form = form;
+ m_options = options;
+ m_bounds = context.Bounds;
+ m_topic = form.Addressing.Target;
+ m_qos = ParseQos(form.Addressing.Metadata);
+ m_retain = ParseBool(form.Addressing.Metadata, "retain");
+ context.Codecs.TrySelect(form.Payload.ContentType, out m_codec);
+ m_client.ApplicationMessageReceivedAsync += OnMessageAsync;
+ }
+
+ public WotCompiledForm Form { get; }
+
+ public async ValueTask ReadAsync(CancellationToken cancellationToken = default)
+ {
+ var completion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ if (Interlocked.CompareExchange(ref m_pendingRead, completion, null) is not null)
+ {
+ var exception = new ServiceResultException(
+ StatusCodes.BadInvalidState,
+ "An MQTT read is already pending on this channel.");
+ completion.SetCanceled(CancellationToken.None);
+ throw exception;
+ }
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(m_options.ReadTimeout);
+ try
+ {
+ await SubscribeAsync(cancellationToken).ConfigureAwait(false);
+ byte[] payload = await completion.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
+ WotDecodeResult decoded = m_codec.Decode(payload, Form.Payload);
+ if (!decoded.Success)
+ {
+ return new WotReadResult(
+ StatusCodes.BadDecodingError,
+ DataValue.FromStatusCode(StatusCodes.BadDecodingError),
+ decoded.Error);
+ }
+ return new WotReadResult(
+ StatusCodes.Good, new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now));
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ completion.TrySetCanceled(timeout.Token);
+ return new WotReadResult(
+ StatusCodes.BadTimeout, DataValue.FromStatusCode(StatusCodes.BadTimeout),
+ "Timed out waiting for an MQTT message.");
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ completion.TrySetCanceled(cancellationToken);
+ throw;
+ }
+ catch
+ {
+ completion.TrySetCanceled(CancellationToken.None);
+ throw;
+ }
+ finally
+ {
+ Interlocked.CompareExchange(ref m_pendingRead, null, completion);
+ if (!m_observing)
+ {
+ await TryUnsubscribeAsync().ConfigureAwait(false);
+ }
+ }
+ }
+
+ public async ValueTask WriteAsync(
+ DataValue value, CancellationToken cancellationToken = default)
+ {
+ WotEncodeResult encoded = m_codec.Encode(value.WrappedValue, Form.Payload);
+ if (!encoded.Success)
+ {
+ return new WotWriteResult(StatusCodes.BadEncodingError, encoded.Error);
+ }
+ try
+ {
+ await PublishAsync(encoded.Data.ToArray(), cancellationToken).ConfigureAwait(false);
+ return new WotWriteResult(StatusCodes.Good);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return new WotWriteResult(StatusCodes.BadTimeout, "The MQTT publish timed out.");
+ }
+ catch (ArgumentException ex)
+ {
+ return new WotWriteResult(StatusCodes.BadInvalidArgument, ex.Message);
+ }
+ catch (MqttCommunicationException ex)
+ {
+ return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message);
+ }
+ }
+
+ public async ValueTask InvokeAsync(
+ IReadOnlyList inputs, CancellationToken cancellationToken = default)
+ {
+ byte[] payload = [];
+ if (inputs is { Count: > 0 })
+ {
+ WotEncodeResult encoded = m_codec.Encode(inputs[0], Form.Payload);
+ if (!encoded.Success)
+ {
+ return new WotInvokeResult(StatusCodes.BadEncodingError, null, encoded.Error);
+ }
+ payload = encoded.Data.ToArray();
+ }
+ try
+ {
+ await PublishAsync(payload, cancellationToken).ConfigureAwait(false);
+ return new WotInvokeResult(StatusCodes.Good, []);
+ }
+ catch (MqttCommunicationException ex)
+ {
+ return new WotInvokeResult(StatusCodes.BadCommunicationError, null, ex.Message);
+ }
+ catch (ArgumentException ex)
+ {
+ return new WotInvokeResult(StatusCodes.BadInvalidArgument, null, ex.Message);
+ }
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")]
+ public async ValueTask ObserveAsync(
+ Action onNotification, CancellationToken cancellationToken = default)
+ {
+ if (onNotification is null)
+ {
+ throw new ArgumentNullException(nameof(onNotification));
+ }
+ lock (m_lock)
+ {
+ m_handlers.Add(onNotification);
+ m_observing = true;
+ }
+ try
+ {
+ await SubscribeAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ RemoveHandler(onNotification);
+ throw;
+ }
+ return new HandlerSubscription(this, onNotification);
+ }
+
+ public ValueTask SubscribeEventAsync(
+ Action onEvent, CancellationToken cancellationToken = default)
+ {
+ return ObserveAsync(onEvent, cancellationToken);
+ }
+
+ public async ValueTask DisposeAsync()
+ {
+ m_client.ApplicationMessageReceivedAsync -= OnMessageAsync;
+ try
+ {
+ await m_client.DisconnectAsync(new MqttClientDisconnectOptionsBuilder().Build())
+ .ConfigureAwait(false);
+ }
+ catch (MqttCommunicationException)
+ {
+ // Ignore disconnect faults during teardown.
+ }
+ m_client.Dispose();
+ }
+
+ private Task OnMessageAsync(MqttApplicationMessageReceivedEventArgs args)
+ {
+ byte[] payload = ToArray(args.ApplicationMessage.Payload);
+ TaskCompletionSource? pending = Interlocked.Exchange(ref m_pendingRead, null);
+ pending?.TrySetResult(payload);
+
+ Action[] handlers;
+ lock (m_lock)
+ {
+ if (m_handlers.Count == 0)
+ {
+ return Task.CompletedTask;
+ }
+ handlers = [.. m_handlers];
+ }
+ WotDecodeResult decoded = m_codec.Decode(payload, Form.Payload);
+ if (decoded.Success)
+ {
+ var notification = new WotNotification(
+ new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now));
+ foreach (Action handler in handlers)
+ {
+ handler(notification);
+ }
+ }
+ return Task.CompletedTask;
+ }
+
+ private async Task SubscribeAsync(CancellationToken cancellationToken)
+ {
+ ValidateSubscribeTopic();
+ MqttClientSubscribeOptions options = new MqttClientSubscribeOptionsBuilder()
+ .WithTopicFilter(m_topic, m_qos)
+ .Build();
+ await m_client.SubscribeAsync(options, cancellationToken).ConfigureAwait(false);
+ }
+
+ private async Task TryUnsubscribeAsync()
+ {
+ try
+ {
+ MqttClientUnsubscribeOptions options = new MqttClientUnsubscribeOptionsBuilder()
+ .WithTopicFilter(m_topic)
+ .Build();
+ await m_client.UnsubscribeAsync(options).ConfigureAwait(false);
+ }
+ catch (MqttCommunicationException)
+ {
+ // Ignore unsubscribe faults.
+ }
+ }
+
+ private async Task PublishAsync(byte[] payload, CancellationToken cancellationToken)
+ {
+ if (ContainsMqttWildcard(m_topic))
+ {
+ throw new ArgumentException("MQTT publish topics must not contain wildcard characters.");
+ }
+ MqttApplicationMessage message = new MqttApplicationMessageBuilder()
+ .WithTopic(m_topic)
+ .WithPayload(payload)
+ .WithQualityOfServiceLevel(m_qos)
+ .WithRetainFlag(m_retain)
+ .Build();
+ await m_client.PublishAsync(message, cancellationToken).ConfigureAwait(false);
+ }
+
+ private void ValidateSubscribeTopic()
+ {
+ WotBindingBounds.EnsurePositive(m_bounds.MaxTopicLength, nameof(WotBindingBounds.MaxTopicLength));
+ if (m_topic.Length > m_bounds.MaxTopicLength)
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadOutOfRange,
+ $"The MQTT subscribe topic exceeds the maximum length of {m_bounds.MaxTopicLength}.");
+ }
+ if (!m_bounds.AllowMqttWildcardTopics && ContainsMqttWildcard(m_topic))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadInvalidArgument,
+ "MQTT subscribe topics must not contain wildcard characters unless explicitly allowed.");
+ }
+ }
+
+ private void RemoveHandler(Action handler)
+ {
+ lock (m_lock)
+ {
+ m_handlers.Remove(handler);
+ m_observing = m_handlers.Count > 0;
+ }
+ }
+
+ private static byte[] ToArray(ReadOnlySequence payload)
+ {
+ return payload.IsEmpty ? [] : payload.ToArray();
+ }
+
+ private static bool ContainsMqttWildcard(string topic)
+ {
+ return topic.Contains('#', StringComparison.Ordinal) || topic.Contains('+', StringComparison.Ordinal);
+ }
+
+ private static MqttQualityOfServiceLevel ParseQos(
+ System.Collections.Immutable.ImmutableDictionary metadata)
+ {
+ if (metadata.TryGetValue("qos", out string? value) &&
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int qos))
+ {
+ return qos switch
+ {
+ 1 => MqttQualityOfServiceLevel.AtLeastOnce,
+ 2 => MqttQualityOfServiceLevel.ExactlyOnce,
+ _ => MqttQualityOfServiceLevel.AtMostOnce
+ };
+ }
+ return MqttQualityOfServiceLevel.AtMostOnce;
+ }
+
+ private static bool ParseBool(
+ System.Collections.Immutable.ImmutableDictionary metadata, string key)
+ {
+ return metadata.TryGetValue(key, out string? value) && bool.TryParse(value, out bool result) && result;
+ }
+
+ private sealed class HandlerSubscription : IWotSubscription
+ {
+ public HandlerSubscription(MqttWotBindingChannel channel, Action handler)
+ {
+ m_channel = channel;
+ m_handler = handler;
+ }
+
+ public WotCompiledForm Form => m_channel.Form;
+
+ public async ValueTask DisposeAsync()
+ {
+ m_channel.RemoveHandler(m_handler);
+ if (!m_channel.m_observing)
+ {
+ await m_channel.TryUnsubscribeAsync().ConfigureAwait(false);
+ }
+ }
+
+ private readonly MqttWotBindingChannel m_channel;
+ private readonly Action m_handler;
+ }
+
+ private readonly IMqttClient m_client;
+ private readonly MqttWotBindingOptions m_options;
+ private readonly WotBindingBounds m_bounds;
+ private readonly string m_topic;
+ private readonly MqttQualityOfServiceLevel m_qos;
+ private readonly bool m_retain;
+ private readonly IWotPayloadCodec m_codec;
+ private readonly Lock m_lock = new();
+ private readonly List> m_handlers = [];
+ private volatile bool m_observing;
+ private TaskCompletionSource? m_pendingRead;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs
new file mode 100644
index 0000000000..9ac6d628f2
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingExecutor.cs
@@ -0,0 +1,101 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using MQTTnet;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.Mqtt
+{
+ ///
+ /// Executes MQTT WoT binding forms compiled by the
+ /// by opening a per-form MQTT connection using
+ /// the repository's MQTTnet infrastructure.
+ ///
+ public sealed class MqttWotBindingExecutor : IWotBindingExecutor
+ {
+ ///
+ /// Initializes a new MQTT executor.
+ ///
+ public MqttWotBindingExecutor(MqttWotBindingOptions? options = null)
+ {
+ m_options = options ?? new MqttWotBindingOptions();
+ }
+
+ ///
+ public WotBindingIdentity Identity { get; } =
+ new WotBindingIdentity("w3c.mqtt", "1.0-ed", MqttBindingPlanner.BindingUri, "W3C WoT MQTT Executor");
+
+ ///
+ public bool CanExecute(WotCompiledForm form)
+ {
+ return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal);
+ }
+
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "The client is owned by the returned channel, which disposes it.")]
+ public async ValueTask ActivateAsync(
+ WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default)
+ {
+ if (form is null)
+ {
+ throw new ArgumentNullException(nameof(form));
+ }
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+ string suffix = Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture);
+ string clientId = $"{m_options.ClientIdPrefix}-{suffix.AsSpan(0, 12)}";
+ // Resolve credentials / trust and build the options first, so a
+ // fail-closed rejection throws before any client is created.
+ MqttWotConnection.MqttWotConnectPlan plan = await MqttWotConnection
+ .PrepareAsync(form, context, m_options, clientId, cancellationToken).ConfigureAwait(false);
+ IMqttClient client = m_options.ClientFactory?.Invoke()
+ ?? new MqttClientFactory().CreateMqttClient();
+ try
+ {
+ await client.ConnectAsync(plan.Options, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ client.Dispose();
+ throw;
+ }
+ return new MqttWotBindingChannel(client, form, context, m_options);
+ }
+
+ private readonly MqttWotBindingOptions m_options;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs
new file mode 100644
index 0000000000..9160837188
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotBindingOptions.cs
@@ -0,0 +1,74 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using MQTTnet;
+
+namespace Opc.Ua.WotCon.Bindings.Mqtt
+{
+ ///
+ /// Options for the MQTT WoT binding executor.
+ ///
+ public sealed class MqttWotBindingOptions
+ {
+ ///
+ /// Gets or sets a factory that supplies an unconnected MQTT client. When
+ /// null the executor creates one from the MQTTnet client factory.
+ ///
+ public Func? ClientFactory { get; set; }
+
+ ///
+ /// Gets or sets the client id prefix used for connections.
+ ///
+ public string ClientIdPrefix { get; set; } = "opcua-wot";
+
+ ///
+ /// Gets or sets the timeout awaiting a message during a read.
+ ///
+ public TimeSpan ReadTimeout { get; set; } = TimeSpan.FromSeconds(10);
+
+ ///
+ /// Gets or sets whether username / password credentials may be sent over a
+ /// plaintext mqtt:// connection. When false (the default) the
+ /// executor fails closed rather than leaking credentials in clear text; use
+ /// an mqtts:// href instead. Set to true only for explicitly
+ /// accepted plaintext deployments.
+ ///
+ public bool AllowCredentialsOverPlaintext { get; set; }
+
+ ///
+ /// Gets or sets whether the broker's TLS certificate is validated for an
+ /// mqtts:// connection. When true (the default) the platform
+ /// trust store, or the trust anchors resolved through the credential
+ /// provider, must validate the broker certificate. Set to false only
+ /// for explicitly accepted test deployments.
+ ///
+ public bool ValidateServerCertificate { get; set; } = true;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs
new file mode 100644
index 0000000000..45f77f1c2d
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/MqttWotConnection.cs
@@ -0,0 +1,206 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MQTTnet;
+
+namespace Opc.Ua.WotCon.Bindings.Mqtt
+{
+ ///
+ /// Builds the transport-security-aware MQTT client options for a compiled WoT
+ /// form. The mqtts scheme always enables TLS (defaulting to port 8883)
+ /// and applies the trust anchors and client certificate resolved through the
+ /// credential provider; the mqtt scheme stays explicit plaintext
+ /// (port 1883). The builder fails closed: a declared security scheme that
+ /// resolves to no credential, or username / password material that would be
+ /// sent over a plaintext connection, throws instead of downgrading silently.
+ ///
+ internal static class MqttWotConnection
+ {
+ internal const int DefaultTlsPort = 8883;
+ internal const int DefaultPlaintextPort = 1883;
+
+ ///
+ /// The result of preparing an MQTT connection for a compiled form.
+ ///
+ internal sealed class MqttWotConnectPlan
+ {
+ public MqttWotConnectPlan(
+ MqttClientOptions options,
+ string host,
+ int port,
+ bool useTls,
+ bool hasCredentials)
+ {
+ Options = options;
+ Host = host;
+ Port = port;
+ UseTls = useTls;
+ HasCredentials = hasCredentials;
+ }
+
+ ///
+ /// Gets the built MQTT client options.
+ ///
+ public MqttClientOptions Options { get; }
+
+ ///
+ /// Gets the resolved broker host.
+ ///
+ public string Host { get; }
+
+ ///
+ /// Gets the resolved broker port.
+ ///
+ public int Port { get; }
+
+ ///
+ /// Gets whether TLS is enabled for the connection.
+ ///
+ public bool UseTls { get; }
+
+ ///
+ /// Gets whether username / password credentials were applied.
+ ///
+ public bool HasCredentials { get; }
+ }
+
+ ///
+ /// Resolves credentials / trust through the provider and builds the MQTT
+ /// client options for the supplied compiled form, enforcing the transport
+ /// security rules described on the type.
+ ///
+ ///
+ public static async ValueTask PrepareAsync(
+ WotCompiledForm form,
+ WotExecutorContext context,
+ MqttWotBindingOptions options,
+ string clientId,
+ CancellationToken cancellationToken)
+ {
+ bool useTls = string.Equals(form.Endpoint.Scheme, "mqtts", StringComparison.OrdinalIgnoreCase);
+ string host = string.IsNullOrEmpty(form.Endpoint.Host) ? "127.0.0.1" : form.Endpoint.Host!;
+ int port = form.Endpoint.Port > 0
+ ? form.Endpoint.Port
+ : (useTls ? DefaultTlsPort : DefaultPlaintextPort);
+
+ WotCredential? credential = await ResolveRequiredCredentialAsync(form, context, cancellationToken)
+ .ConfigureAwait(false);
+
+ MqttClientOptionsBuilder builder = new MqttClientOptionsBuilder()
+ .WithTcpServer(host, port)
+ .WithClientId(clientId);
+
+ string? username = null;
+ byte[] password = [];
+ if (credential is not null)
+ {
+ if (credential.Properties.TryGetValue("username", out string? user))
+ {
+ username = user;
+ }
+ if (credential.Properties.TryGetValue("password", out string? pass) && pass is not null)
+ {
+ password = Encoding.UTF8.GetBytes(pass);
+ }
+ }
+
+ bool hasCredentials = !string.IsNullOrEmpty(username);
+ if (hasCredentials && !useTls && !options.AllowCredentialsOverPlaintext)
+ {
+ throw new InvalidOperationException(
+ "MQTT username / password credentials require TLS. Use an mqtts:// href, or set " +
+ "MqttWotBindingOptions.AllowCredentialsOverPlaintext for explicitly accepted " +
+ "plaintext deployments.");
+ }
+ if (hasCredentials)
+ {
+ builder = builder.WithCredentials(username, password);
+ }
+
+ if (useTls)
+ {
+ X509Certificate2? clientCertificate = credential?.ClientCertificate;
+ ImmutableArray trust = credential is null
+ ? []
+ : credential.TrustedCertificates;
+ bool validate = options.ValidateServerCertificate;
+ builder = builder.WithTlsOptions(tls =>
+ {
+ tls.UseTls().WithAllowUntrustedCertificates(!validate);
+ if (clientCertificate is not null)
+ {
+ tls.WithClientCertificates([clientCertificate]);
+ }
+ if (!trust.IsDefaultOrEmpty)
+ {
+ var chain = new X509Certificate2Collection();
+ foreach (X509Certificate2 anchor in trust)
+ {
+ chain.Add(anchor);
+ }
+ tls.WithTrustChain(chain);
+ }
+ });
+ }
+
+ return new MqttWotConnectPlan(builder.Build(), host, port, useTls, hasCredentials);
+ }
+
+ private static async ValueTask ResolveRequiredCredentialAsync(
+ WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken)
+ {
+ if (form.Security.IsDefaultOrEmpty)
+ {
+ return null;
+ }
+ foreach (WotCredentialReference reference in form.Security)
+ {
+ if (reference.Scheme == WotSecurityScheme.NoSecurity)
+ {
+ continue;
+ }
+ // Fail closed: a form that declares a security scheme must have
+ // its credential resolved or the connection is refused rather
+ // than silently opened without the required authentication.
+ return (WotCredential?)(await context.Credentials
+ .ResolveAsync(reference, cancellationToken).ConfigureAwait(false) ??
+ throw new InvalidOperationException(
+ $"The MQTT binding requires a credential for security scheme '{reference.SchemeName}' " +
+ "but the credential provider resolved none; refusing to connect."));
+ }
+ return null;
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md b/src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md
new file mode 100644
index 0000000000..44372ee63c
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/NugetREADME.md
@@ -0,0 +1,17 @@
+# OPC UA WoT Connectivity — MQTT Executor
+
+`OPCFoundation.NetStandard.Opc.Ua.WotCon.Bindings.Mqtt` executes MQTT WoT Connectivity binding forms compiled by the MQTT planner in `Opc.Ua.WotCon.Bindings`.
+
+It uses the repository's MQTTnet infrastructure (kept out of the core model assembly) to implement publish / subscribe / RPC patterns per the pinned MQTT binding, with bounded QoS, topic, payload sizes and timeouts. The MQTT client factory is injectable.
+
+## Transport security
+
+- An `mqtts://` href always enables TLS and defaults to port 8883; an `mqtt://` href stays explicit plaintext (port 1883). There is no silent plaintext downgrade.
+- Username / password credentials, the TLS client certificate and the TLS trust anchors are resolved through the registered `IWotCredentialProvider`. A form that declares a security scheme fails closed (the connection is refused) when the provider resolves no credential.
+- Username / password credentials are refused over a plaintext `mqtt://` connection unless `MqttWotBindingOptions.AllowCredentialsOverPlaintext` is set for an explicitly accepted plaintext deployment. `ValidateServerCertificate` controls broker-certificate validation for `mqtts://`.
+
+Register it with `builder.AddMqttWotBinding(...)`.
+
+The package targets `net8.0`, `net9.0`, and `net10.0`. The MQTT planner and common binding contracts are supplied by `OPCFoundation.NetStandard.Opc.Ua.WotCon.Bindings`.
+
+See the [WoT protocol bindings guide](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/docs/WotBindings.md).
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj b/src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj
new file mode 100644
index 0000000000..b2ee1a9c28
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/Opc.Ua.WotCon.Bindings.Mqtt.csproj
@@ -0,0 +1,33 @@
+
+
+ $(AssemblyPrefix).WotCon.Bindings.Mqtt
+ net8.0;net9.0;net10.0
+ $(CustomTestTarget)
+ $(CustomTestTarget)
+ true
+ $(PackagePrefix).Opc.Ua.WotCon.Bindings.Mqtt
+ Opc.Ua.WotCon.Bindings.Mqtt
+ enable
+ MQTT protocol executor for OPC UA WoT Connectivity binding forms
+ true
+ NugetREADME.md
+ true
+ true
+
+
+
+
+
+
+ $(PackageId).Debug
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs
new file mode 100644
index 0000000000..9bf43caaa7
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/OpcUaMqttWotBindingBuilderExtensions.cs
@@ -0,0 +1,61 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.WotCon.Bindings.Mqtt;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// extensions that register the MQTT WoT binding
+ /// executor alongside the shipped planner binders.
+ ///
+ public static class OpcUaMqttWotBindingBuilderExtensions
+ {
+ ///
+ /// Registers the eight shipped planner binders and the MQTT executor, so
+ /// MQTT binding forms are validated, compiled and executable.
+ ///
+ ///
+ public static IOpcUaBuilder AddMqttWotBinding(
+ this IOpcUaBuilder builder, Action? configure = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ var options = new MqttWotBindingOptions();
+ configure?.Invoke(options);
+ return builder
+ .AddWotProtocolBinders()
+ .AddWotBindingExecutor(new MqttWotBindingExecutor(options));
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs b/src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..7798c9bd57
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings.Mqtt/Properties/AssemblyInfo.cs
@@ -0,0 +1,32 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+[assembly: CLSCompliant(false)]
diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs
new file mode 100644
index 0000000000..ca075e53be
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpStatusMapper.cs
@@ -0,0 +1,62 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Net;
+
+namespace Opc.Ua.WotCon.Bindings.Http
+{
+ ///
+ /// Maps HTTP status codes to OPC UA values.
+ ///
+ internal static class HttpStatusMapper
+ {
+ public static StatusCode Map(HttpStatusCode status)
+ {
+ int code = (int)status;
+ if (code is >= 200 and < 300)
+ {
+ return StatusCodes.Good;
+ }
+ return status switch
+ {
+ HttpStatusCode.BadRequest => StatusCodes.BadInvalidArgument,
+ HttpStatusCode.Unauthorized => StatusCodes.BadUserAccessDenied,
+ HttpStatusCode.Forbidden => StatusCodes.BadUserAccessDenied,
+ HttpStatusCode.NotFound => StatusCodes.BadNodeIdUnknown,
+ HttpStatusCode.MethodNotAllowed => StatusCodes.BadNotSupported,
+ HttpStatusCode.RequestTimeout => StatusCodes.BadTimeout,
+ HttpStatusCode.Conflict => StatusCodes.BadInvalidState,
+ HttpStatusCode.NotImplemented => StatusCodes.BadNotImplemented,
+ HttpStatusCode.ServiceUnavailable => StatusCodes.BadServerHalted,
+ HttpStatusCode.GatewayTimeout => StatusCodes.BadTimeout,
+ _ => code >= 500 ? StatusCodes.BadInternalError : StatusCodes.BadUnexpectedError
+ };
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs
new file mode 100644
index 0000000000..58770d9d86
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingChannel.cs
@@ -0,0 +1,565 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.IO;
+using System.Net.Http;
+using System.Net.Http.Headers;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.WotCon.Bindings.Http
+{
+ ///
+ /// A live HTTP binding channel. It executes read (GET), write (PUT/method),
+ /// action (POST/method), observe and event operations with bounded timeouts
+ /// and payload sizes, cooperative cancellation, HTTP-to-
+ /// mapping and credential-provider-driven authentication.
+ ///
+ internal sealed class HttpWotBindingChannel : IWotBindingChannel
+ {
+ public HttpWotBindingChannel(
+ HttpClient client,
+ bool ownsClient,
+ bool manualRedirects,
+ ImmutableArray> defaultHeaders,
+ WotCompiledForm form,
+ WotExecutorContext context,
+ HttpWotBindingOptions options)
+ {
+ m_client = client;
+ m_ownsClient = ownsClient;
+ m_manualRedirects = manualRedirects;
+ m_defaultHeaders = defaultHeaders;
+ Form = form;
+ m_context = context;
+ m_options = options;
+ context.Codecs.TrySelect(form.Payload.ContentType, out m_codec);
+ m_baseTarget = form.Addressing.Target;
+ }
+
+ public WotCompiledForm Form { get; }
+
+ public async ValueTask ReadAsync(CancellationToken cancellationToken = default)
+ {
+ (StatusCode status, byte[] body, string? error) =
+ await SendAsync(HttpMethod.Get, null, cancellationToken).ConfigureAwait(false);
+ if (!StatusCode.IsGood(status))
+ {
+ return new WotReadResult(status, DataValue.FromStatusCode(status), error);
+ }
+ WotDecodeResult decoded = m_codec.Decode(body, Form.Payload);
+ if (!decoded.Success)
+ {
+ return new WotReadResult(
+ StatusCodes.BadDecodingError,
+ DataValue.FromStatusCode(StatusCodes.BadDecodingError),
+ decoded.Error);
+ }
+ return new WotReadResult(
+ StatusCodes.Good,
+ new DataValue(decoded.Value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now));
+ }
+
+ public async ValueTask WriteAsync(
+ DataValue value, CancellationToken cancellationToken = default)
+ {
+ WotEncodeResult encoded = m_codec.Encode(value.WrappedValue, Form.Payload);
+ if (!encoded.Success)
+ {
+ return new WotWriteResult(StatusCodes.BadEncodingError, encoded.Error);
+ }
+ HttpMethod method = ResolveMethod("PUT");
+ (StatusCode status, _, string? error) =
+ await SendAsync(method, encoded.Data, cancellationToken).ConfigureAwait(false);
+ return new WotWriteResult(status, error);
+ }
+
+ public async ValueTask InvokeAsync(
+ IReadOnlyList inputs, CancellationToken cancellationToken = default)
+ {
+ ReadOnlyMemory? content = null;
+ if (inputs is { Count: > 0 })
+ {
+ WotEncodeResult encoded = m_codec.Encode(inputs[0], Form.Payload);
+ if (!encoded.Success)
+ {
+ return new WotInvokeResult(StatusCodes.BadEncodingError, null, encoded.Error);
+ }
+ content = encoded.Data;
+ }
+ HttpMethod method = ResolveMethod("POST");
+ (StatusCode status, byte[] body, string? error) =
+ await SendAsync(method, content, cancellationToken).ConfigureAwait(false);
+ if (!StatusCode.IsGood(status))
+ {
+ return new WotInvokeResult(status, null, error);
+ }
+ if (body.Length == 0)
+ {
+ return new WotInvokeResult(StatusCodes.Good, []);
+ }
+ WotDecodeResult decoded = m_codec.Decode(body, Form.Payload);
+ var output = new DataValue(
+ decoded.Success ? decoded.Value : Variant.Null,
+ decoded.Success ? StatusCodes.Good : StatusCodes.BadDecodingError,
+ DateTimeUtc.Now, DateTimeUtc.Now);
+ return new WotInvokeResult(StatusCodes.Good, [output]);
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")]
+ public ValueTask ObserveAsync(
+ Action onNotification, CancellationToken cancellationToken = default)
+ {
+ if (onNotification is null)
+ {
+ throw new ArgumentNullException(nameof(onNotification));
+ }
+ var subscription = new PollingWotSubscription(
+ Form,
+ async token =>
+ {
+ WotReadResult result = await ReadAsync(token).ConfigureAwait(false);
+ // A mapped failure carries its bad StatusCode on the value, so surface it
+ // rather than leaving the last good value in place, and report the poll as
+ // unhealthy so the retry policy backs off instead of hammering the asset.
+ onNotification(new WotNotification(result.Value));
+ return result.Success;
+ },
+ m_options.ObserveInterval,
+ // A transient poll fault is reported as a Bad-status notification
+ // so consumers observe the fault without the poll loop faulting.
+ onError: _ => onNotification(new WotNotification(
+ DataValue.FromStatusCode(StatusCodes.BadCommunicationError))),
+ retryPolicy: m_options.RetryPolicy,
+ telemetry: m_context.Telemetry);
+ return new ValueTask(subscription);
+ }
+
+ public ValueTask SubscribeEventAsync(
+ Action onEvent, CancellationToken cancellationToken = default)
+ {
+ return ObserveAsync(onEvent, cancellationToken);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ if (m_ownsClient)
+ {
+ m_client.Dispose();
+ }
+ return default;
+ }
+
+ private HttpMethod ResolveMethod(string fallback)
+ {
+ string method = string.IsNullOrEmpty(Form.OperationInfo.Method)
+ ? fallback : Form.OperationInfo.Method;
+ return new HttpMethod(method.ToUpperInvariant());
+ }
+
+ private async ValueTask<(StatusCode Status, byte[] Body, string? Error)> SendAsync(
+ HttpMethod method, ReadOnlyMemory? content, CancellationToken cancellationToken)
+ {
+ await EnsureCredentialAsync(cancellationToken).ConfigureAwait(false);
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(m_context.Bounds.DefaultTimeout);
+ try
+ {
+ if (!Uri.TryCreate(m_baseTarget, UriKind.Absolute, out Uri? current) || current is null)
+ {
+ return (StatusCodes.BadInvalidArgument, Array.Empty(),
+ "The HTTP target is not a valid absolute URI.");
+ }
+ Uri origin = current;
+ HttpMethod currentMethod = method;
+ ReadOnlyMemory? currentContent = content;
+ int redirectsRemaining = m_manualRedirects ? Math.Max(0, m_options.MaxAutomaticRedirects) : 0;
+ var visited = new HashSet(StringComparer.OrdinalIgnoreCase);
+ while (true)
+ {
+ visited.Add(current.AbsoluteUri);
+ // Custom header / query credentials are only applied while the
+ // request stays on the original origin; a cross-origin redirect
+ // drops them so they never leak to a different host.
+ bool sameOrigin = IsSameOrigin(origin, current);
+ Uri requestUri = sameOrigin ? AppendCredentialQuery(current) : current;
+ HopResult hop = await SendOnceAsync(
+ currentMethod, requestUri, sameOrigin, currentContent, timeout.Token).ConfigureAwait(false);
+
+ if (hop.Redirect is null)
+ {
+ return (hop.Status, hop.Body, hop.Error);
+ }
+
+ if (redirectsRemaining <= 0)
+ {
+ return (StatusCodes.BadCommunicationError, Array.Empty(),
+ "The HTTP redirect limit was exceeded.");
+ }
+ Uri? next = ResolveRedirectTarget(current, hop.Location, out string? redirectError);
+ if (next is null)
+ {
+ return (StatusCodes.BadSecurityChecksFailed, Array.Empty(), redirectError);
+ }
+ if (visited.Contains(next.AbsoluteUri))
+ {
+ return (StatusCodes.BadCommunicationError, Array.Empty(),
+ "The HTTP redirect chain contains a loop.");
+ }
+ redirectsRemaining--;
+ // 303 (and, per browser convention, 301/302) turn the follow-up
+ // request into a bodyless GET; 307/308 preserve method and body.
+ if (hop.Redirect is System.Net.HttpStatusCode.MovedPermanently or
+ System.Net.HttpStatusCode.Found or System.Net.HttpStatusCode.SeeOther)
+ {
+ currentMethod = HttpMethod.Get;
+ currentContent = null;
+ }
+ current = next;
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return (StatusCodes.BadTimeout, Array.Empty(), "The HTTP request timed out.");
+ }
+ catch (HttpRequestException ex)
+ {
+ return (StatusCodes.BadCommunicationError, Array.Empty(), ex.Message);
+ }
+ catch (InvalidOperationException ex)
+ {
+ return (StatusCodes.BadEncodingLimitsExceeded, Array.Empty(), ex.Message);
+ }
+ }
+
+ ///
+ /// The outcome of a single request hop: either a terminal result or a redirect.
+ ///
+ private readonly struct HopResult
+ {
+ private HopResult(
+ System.Net.HttpStatusCode? redirect, Uri? location,
+ StatusCode status, byte[] body, string? error)
+ {
+ Redirect = redirect;
+ Location = location;
+ Status = status;
+ Body = body;
+ Error = error;
+ }
+
+ public System.Net.HttpStatusCode? Redirect { get; }
+
+ public Uri? Location { get; }
+
+ public StatusCode Status { get; }
+
+ public byte[] Body { get; }
+
+ public string? Error { get; }
+
+ public static HopResult Terminal(StatusCode status, byte[] body, string? error)
+ {
+ return new HopResult(null, null, status, body, error);
+ }
+
+ public static HopResult RedirectTo(System.Net.HttpStatusCode redirect, Uri? location)
+ {
+ return new HopResult(redirect, location, StatusCodes.Good, [], null);
+ }
+ }
+
+ private async Task SendOnceAsync(
+ HttpMethod method, Uri requestUri, bool sameOrigin,
+ ReadOnlyMemory? content, CancellationToken cancellationToken)
+ {
+ using var request = new HttpRequestMessage(method, requestUri);
+ string? headerError = ApplyHeaders(request, sameOrigin);
+ if (headerError is not null)
+ {
+ return HopResult.Terminal(StatusCodes.BadSecurityChecksFailed, [], headerError);
+ }
+ if (content is { } body && method != HttpMethod.Get && method != HttpMethod.Head)
+ {
+ MediaTypeHeaderValue? mediaType = null;
+ if (!string.IsNullOrEmpty(Form.Payload.ContentType) &&
+ !MediaTypeHeaderValue.TryParse(Form.Payload.ContentType, out mediaType))
+ {
+ return HopResult.Terminal(
+ StatusCodes.BadInvalidArgument,
+ [],
+ "The form contentType is not a valid media type.");
+ }
+ var byteContent = new ByteArrayContent(body.ToArray());
+ if (mediaType is not null)
+ {
+ byteContent.Headers.ContentType = mediaType;
+ }
+ request.Content = byteContent;
+ }
+
+ using HttpResponseMessage response = await m_client
+ .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (m_manualRedirects && IsRedirect(response.StatusCode))
+ {
+ return HopResult.RedirectTo(response.StatusCode, response.Headers.Location);
+ }
+
+ StatusCode status = HttpStatusMapper.Map(response.StatusCode);
+ if (!response.IsSuccessStatusCode)
+ {
+ return HopResult.Terminal(status, [],
+ $"HTTP {(int)response.StatusCode} {response.ReasonPhrase}");
+ }
+ byte[] payload = await ReadBoundedAsync(response, cancellationToken).ConfigureAwait(false);
+ return HopResult.Terminal(StatusCodes.Good, payload, null);
+ }
+
+ private static bool IsRedirect(System.Net.HttpStatusCode status)
+ {
+ return status is System.Net.HttpStatusCode.MovedPermanently or
+ System.Net.HttpStatusCode.Found or
+ System.Net.HttpStatusCode.SeeOther or
+ System.Net.HttpStatusCode.TemporaryRedirect or
+ System.Net.HttpStatusCode.PermanentRedirect;
+ }
+
+ private Uri? ResolveRedirectTarget(Uri current, Uri? location, out string? error)
+ {
+ error = null;
+ if (location is null)
+ {
+ error = "The HTTP redirect response carried no Location header.";
+ return null;
+ }
+ if (!location.IsAbsoluteUri)
+ {
+ location = new Uri(current, location);
+ }
+ if (!string.Equals(location.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
+ !string.Equals(location.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
+ {
+ error = $"The HTTP redirect targets a disallowed scheme '{location.Scheme}'.";
+ return null;
+ }
+ if (string.Equals(current.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(location.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
+ !m_options.AllowInsecureRedirectDowngrade)
+ {
+ error = "The HTTP redirect downgrades https to http, which is refused.";
+ return null;
+ }
+
+ // The endpoint policy is applied to the form's own target before the channel
+ // opens, but a redirect chooses a new target after that check. Re-apply the
+ // policy to every hop, otherwise a permitted host can bounce the request to a
+ // loopback or link-local address that the initial validation would have refused.
+ ServiceResult validation = WotEndpointValidator.Validate(
+ location.AbsoluteUri,
+ m_context.EndpointPolicy,
+ out _);
+ if (ServiceResult.IsBad(validation))
+ {
+ error = $"The HTTP redirect targets an endpoint refused by policy: {validation.StatusCode}.";
+ return null;
+ }
+ return location;
+ }
+
+ private static bool IsSameOrigin(Uri a, Uri b)
+ {
+ return string.Equals(a.Scheme, b.Scheme, StringComparison.OrdinalIgnoreCase) &&
+ string.Equals(a.Host, b.Host, StringComparison.OrdinalIgnoreCase) &&
+ a.Port == b.Port;
+ }
+
+ private async Task ReadBoundedAsync(HttpResponseMessage response, CancellationToken cancellationToken)
+ {
+ using Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ using var buffer = new MemoryStream();
+ byte[] chunk = new byte[8192];
+ int max = m_context.Bounds.MaxPayloadBytes;
+ int total = 0;
+ int read;
+ while ((read = await stream.ReadAsync(chunk.AsMemory(0, chunk.Length), cancellationToken)
+ .ConfigureAwait(false)) > 0)
+ {
+ total += read;
+ if (total > max)
+ {
+ throw new InvalidOperationException(
+ $"The HTTP response exceeds the maximum payload size of {max} bytes.");
+ }
+ buffer.Write(chunk, 0, read);
+ }
+ return buffer.ToArray();
+ }
+
+ private async ValueTask EnsureCredentialAsync(CancellationToken cancellationToken)
+ {
+ Task task;
+ lock (m_credentialLock)
+ {
+ // Start (or reuse) a single shared resolution. Concurrent callers
+ // all await the same task, so the resolved credential and the
+ // effective target are published exactly once and no request is
+ // ever sent before that state is ready.
+ task = m_credentialTask ??= ResolveCredentialAsync(cancellationToken);
+ }
+ try
+ {
+ await task.ConfigureAwait(false);
+ }
+ catch
+ {
+ // Failure retry policy: a failed (or cancelled) resolution is not
+ // cached, so the next request re-attempts resolution instead of
+ // being permanently wedged on the fault.
+ lock (m_credentialLock)
+ {
+ if (ReferenceEquals(m_credentialTask, task))
+ {
+ m_credentialTask = null;
+ }
+ }
+ throw;
+ }
+ }
+
+ private async Task ResolveCredentialAsync(CancellationToken cancellationToken)
+ {
+ WotCredential? credential = null;
+ if (!Form.Security.IsEmpty)
+ {
+ credential = await m_context.Credentials
+ .ResolveAsync(Form.Security[0], cancellationToken).ConfigureAwait(false);
+ }
+ // Publish the resolved credential only after resolution has completed. A
+ // caller reads m_credential in SendAsync only after awaiting the shared
+ // task, so it can never observe a half-initialized state or send a
+ // request without the resolved credential applied.
+ m_credential = credential;
+ }
+
+ private static bool TryAddHeader(HttpRequestMessage request, KeyValuePair header)
+ {
+ try
+ {
+ request.Headers.Add(header.Key, header.Value);
+ return true;
+ }
+ catch (FormatException)
+ {
+ return false;
+ }
+ catch (InvalidOperationException)
+ {
+ return false;
+ }
+ catch (ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ private string? ApplyHeaders(HttpRequestMessage request, bool includeCredentials)
+ {
+ // A cross-origin redirect must not carry any custom (potentially
+ // credential-bearing) header, so both the caller's default headers and
+ // the resolved credential headers are only applied on the original
+ // origin.
+ if (!includeCredentials)
+ {
+ return null;
+ }
+ foreach (KeyValuePair header in m_defaultHeaders)
+ {
+ if (!TryAddHeader(request, header))
+ {
+ return "A configured default HTTP header is not valid.";
+ }
+ }
+ if (m_credential is { } credential)
+ {
+ foreach (KeyValuePair header in credential.Headers)
+ {
+ if (!TryAddHeader(request, header))
+ {
+ return "A resolved credential HTTP header is not valid.";
+ }
+ }
+ }
+ return null;
+ }
+
+ private Uri AppendCredentialQuery(Uri target)
+ {
+ WotCredential? credential = m_credential;
+ if (credential is null || credential.QueryParameters.Count == 0)
+ {
+ return target;
+ }
+ var query = new StringBuilder();
+ foreach (KeyValuePair parameter in credential.QueryParameters)
+ {
+ if (query.Length > 0)
+ {
+ query.Append('&');
+ }
+ query.Append(Uri.EscapeDataString(parameter.Key)).Append('=')
+ .Append(Uri.EscapeDataString(parameter.Value));
+ }
+ var builder = new UriBuilder(target);
+ builder.Query = string.IsNullOrEmpty(builder.Query)
+ ? query.ToString()
+ : builder.Query.TrimStart('?') + "&" + query;
+ return builder.Uri;
+ }
+
+ private readonly HttpClient m_client;
+ private readonly bool m_ownsClient;
+ private readonly bool m_manualRedirects;
+ private readonly ImmutableArray> m_defaultHeaders;
+ private readonly WotExecutorContext m_context;
+ private readonly HttpWotBindingOptions m_options;
+ private readonly IWotPayloadCodec m_codec;
+ private readonly string m_baseTarget;
+ private WotCredential? m_credential;
+ private readonly Lock m_credentialLock = new();
+ private Task? m_credentialTask;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs
new file mode 100644
index 0000000000..2cc73e0e24
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingExecutor.cs
@@ -0,0 +1,128 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Net.Http;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.Http
+{
+ ///
+ /// Executes HTTP / HTTPS WoT binding forms compiled by the
+ /// . It opens a per-form
+ /// using an injectable
+ /// factory.
+ ///
+ public sealed class HttpWotBindingExecutor : IWotBindingExecutor
+ {
+ ///
+ /// Initializes a new HTTP executor.
+ ///
+ public HttpWotBindingExecutor(HttpWotBindingOptions? options = null)
+ {
+ m_options = options ?? new HttpWotBindingOptions();
+ }
+
+ ///
+ public WotBindingIdentity Identity { get; } =
+ new WotBindingIdentity("w3c.http", "1.1", HttpBindingPlanner.BindingUri, "W3C WoT HTTP Executor");
+
+ ///
+ public bool CanExecute(WotCompiledForm form)
+ {
+ if (form is null)
+ {
+ return false;
+ }
+ return string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal) &&
+ (string.Equals(form.Endpoint.Scheme, "http", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(form.Endpoint.Scheme, "https", StringComparison.OrdinalIgnoreCase));
+ }
+
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "The client and channel are owned by the returned channel, disposed via DisposeAsync.")]
+ public ValueTask ActivateAsync(
+ WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default)
+ {
+ if (form is null)
+ {
+ throw new ArgumentNullException(nameof(form));
+ }
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+ ImmutableArray> defaultHeaders =
+ SnapshotDefaultHeaders(m_options.DefaultHeaders);
+ Func? clientFactory = m_options.ClientFactory;
+ bool ownsClient = clientFactory is null;
+ if (!ownsClient && !m_options.CallerClientHandlesRedirectSafety)
+ {
+ // Fail closed: the executor cannot control a caller-supplied client's
+ // redirect handler, cookie jar, DefaultRequestHeaders or later
+ // mutations. Require explicit confirmation even when the current form
+ // and options appear not to carry credentials.
+ throw new InvalidOperationException(
+ "Every caller-supplied HttpClient requires " +
+ "HttpWotBindingOptions.CallerClientHandlesRedirectSafety to be set. The supplied client must " +
+ "disable automatic redirects, or follow them without forwarding cookies or credentials " +
+ "across origins, " +
+ "because its handler, cookie behavior, mutable DefaultRequestHeaders and later mutations " +
+ "are outside the executor's control.");
+ }
+ HttpClient client = ownsClient
+ ? new HttpClient(new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ UseCookies = false,
+ CheckCertificateRevocationList = true
+ })
+ : clientFactory!.Invoke();
+ IWotBindingChannel channel = new HttpWotBindingChannel(
+ client, ownsClient, manualRedirects: ownsClient, defaultHeaders, form, context, m_options);
+ return new ValueTask(channel);
+ }
+
+ private static ImmutableArray> SnapshotDefaultHeaders(
+ IReadOnlyDictionary? defaultHeaders)
+ {
+ return defaultHeaders is null
+ ? []
+ : ImmutableArray.CreateRange(defaultHeaders);
+ }
+
+ private readonly HttpWotBindingOptions m_options;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs
new file mode 100644
index 0000000000..29c55285f2
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Http/HttpWotBindingOptions.cs
@@ -0,0 +1,112 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Net.Http;
+
+namespace Opc.Ua.WotCon.Bindings.Http
+{
+ ///
+ /// Options for the HTTP WoT binding executor. The client factory is injectable
+ /// so callers can supply a pooled, mutually-authenticated or test
+ /// ; when none is supplied the executor owns a private
+ /// client. Default headers are applied to every request in addition to any
+ /// credential the provider resolves and are treated as sensitive by the
+ /// redirect policy.
+ ///
+ public sealed class HttpWotBindingOptions
+ {
+ ///
+ /// Gets or sets the factory that supplies the . When
+ /// null the executor creates and owns a private client whose handler
+ /// disables automatic redirects and ambient cookie handling, so the executor
+ /// can apply a bounded, origin-aware redirect policy that never leaks
+ /// credentials across origins. A supplied client is treated as caller-owned
+ /// and is never disposed or mutated by the executor. Every supplied client
+ /// must satisfy
+ /// , regardless of the
+ /// currently configured headers or form security.
+ ///
+ public Func? ClientFactory { get; set; }
+
+ ///
+ /// Gets or sets default headers applied to requests on the original origin
+ /// and any same-origin redirect. All configured headers are treated as
+ /// sensitive and are stripped from cross-origin redirects. The collection is
+ /// copied into immutable per-channel state during activation; later changes
+ /// to this property or its original collection do not affect an active
+ /// channel.
+ ///
+ public IReadOnlyDictionary? DefaultHeaders { get; set; }
+
+ ///
+ /// Gets or sets the poll interval used for observe / event operations.
+ ///
+ public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the retry policy applied after consecutive unhealthy polls during an
+ /// observe operation. Defaults to the stack's exponential backoff
+ /// (, 500 ms doubling to 30 s), so an
+ /// asset that has gone offline is not polled once per interval. The backoff never polls
+ /// faster than .
+ ///
+ public IChannelReconnectPolicy? RetryPolicy { get; set; }
+
+ ///
+ /// Gets or sets the maximum number of redirects the executor-owned client
+ /// follows for a single request. The default is 5; 0 disables
+ /// redirect following entirely. Default headers and custom header / query
+ /// credentials are stripped whenever a redirect crosses to a different
+ /// origin.
+ ///
+ public int MaxAutomaticRedirects { get; set; } = 5;
+
+ ///
+ /// Gets or sets whether the executor-owned client may follow a redirect that
+ /// downgrades the scheme from https to http. The default is
+ /// false: an insecure downgrade is refused.
+ ///
+ public bool AllowInsecureRedirectDowngrade { get; set; }
+
+ ///
+ /// Gets or sets whether a caller-supplied is trusted
+ /// to handle redirects safely. The default is false: every
+ /// caller-supplied client fails closed because the executor cannot inspect or
+ /// control its redirect handler, automatic cookie handling and
+ /// , mutable
+ /// , or later caller
+ /// mutations. Set to true only when the supplied client is known to
+ /// disable automatic redirects, or to follow them without forwarding
+ /// credentials to a different origin.
+ ///
+ public bool CallerClientHandlesRedirectSafety { get; set; }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs
new file mode 100644
index 0000000000..32029a4b38
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Http/OpcUaHttpWotBindingBuilderExtensions.cs
@@ -0,0 +1,61 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.WotCon.Bindings.Http;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// extensions that register the HTTP WoT binding
+ /// executor alongside the shipped planner binders.
+ ///
+ public static class OpcUaHttpWotBindingBuilderExtensions
+ {
+ ///
+ /// Registers the eight shipped planner binders and the HTTP executor, so
+ /// HTTP binding forms are validated, compiled and executable.
+ ///
+ ///
+ public static IOpcUaBuilder AddHttpWotBinding(
+ this IOpcUaBuilder builder, Action? configure = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ var options = new HttpWotBindingOptions();
+ configure?.Invoke(options);
+ return builder
+ .AddWotProtocolBinders()
+ .AddWotBindingExecutor(new HttpWotBindingExecutor(options));
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs
new file mode 100644
index 0000000000..4cb52046db
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusAddressing.cs
@@ -0,0 +1,371 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// A validated Modbus operation. The numeric values are the function codes.
+ ///
+ internal enum ModbusOperation : byte
+ {
+ ReadCoils = 1,
+ ReadDiscreteInputs = 2,
+ ReadHoldingRegisters = 3,
+ ReadInputRegisters = 4,
+ WriteSingleCoil = 5,
+ WriteSingleHoldingRegister = 6,
+ WriteMultipleCoils = 15,
+ WriteMultipleHoldingRegisters = 16
+ }
+
+ ///
+ /// The validated Modbus operation and addressing parsed from a compiled form.
+ /// The executor checks method, entity, direction, function metadata and bounds
+ /// before values are narrowed to / , so
+ /// a hand-built or tampered form cannot select a fallback function or silently
+ /// truncate an out-of-range value.
+ ///
+ internal readonly struct ModbusAddressing
+ {
+ private ModbusAddressing(
+ ModbusOperation operation, ushort address, ushort quantity, byte unitId,
+ string type, bool msbFirst, bool mswFirst)
+ {
+ Operation = operation;
+ Address = address;
+ Quantity = quantity;
+ UnitId = unitId;
+ Type = type;
+ MsbFirst = msbFirst;
+ MswFirst = mswFirst;
+ }
+
+ public ModbusOperation Operation { get; }
+
+ public ushort Address { get; }
+
+ public ushort Quantity { get; }
+
+ public byte UnitId { get; }
+
+ public string Type { get; }
+
+ public bool MsbFirst { get; }
+
+ public bool MswFirst { get; }
+
+ ///
+ /// Parses and validates the addressing carried by a compiled Modbus form,
+ /// including operation consistency and protocol bounds.
+ ///
+ ///
+ ///
+ ///
+ public static ModbusAddressing FromForm(WotCompiledForm form, WotBindingBounds? bounds = null)
+ {
+ if (form is null)
+ {
+ throw new ArgumentNullException(nameof(form));
+ }
+ ImmutableDictionary map = form.Addressing.Metadata;
+ string entity = GetRequiredString(map, "entity", form);
+ int address = GetRequiredInt(map, "address", form);
+ int quantity = GetRequiredInt(map, "quantity", form);
+ int unitId = GetRequiredInt(map, "unitId", form);
+ bounds ??= WotBindingBounds.Default;
+
+ if (address is < 0 or > ModbusProtocolLimits.MaxAddress)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(form), address,
+ $"The Modbus address must be between 0 and {ModbusProtocolLimits.MaxAddress}.");
+ }
+ if (quantity is < 1 or > ushort.MaxValue)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(form), quantity, $"The Modbus quantity must be between 1 and {ushort.MaxValue}.");
+ }
+ ModbusOperation operation = ValidateOperation(form, map, entity);
+ bool bitOperation = IsBitOperation(operation);
+ int configuredMaxQuantity = bitOperation
+ ? bounds.MaxCoilQuantity
+ : bounds.MaxRegisterQuantity;
+ int protocolMaxQuantity = ProtocolMaximum(operation);
+ int maxQuantity = Math.Min(configuredMaxQuantity, protocolMaxQuantity);
+ if (IsSingleWrite(operation) && quantity != 1)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(form), quantity,
+ $"The Modbus {CanonicalMethod(operation)} function requires a quantity of 1.");
+ }
+ if (quantity > maxQuantity)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(form), quantity,
+ $"The Modbus quantity must not exceed {maxQuantity} for '{form.OperationInfo.Method}'.");
+ }
+ if (address + quantity - 1 > ModbusProtocolLimits.MaxAddress)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(form), address,
+ $"The Modbus range starting at {address} for {quantity} items exceeds the maximum " +
+ $"address {ModbusProtocolLimits.MaxAddress}.");
+ }
+ if (unitId is < 0 or > 255)
+ {
+ throw new ArgumentOutOfRangeException(
+ nameof(form), unitId, "The Modbus unit id must be between 0 and 255.");
+ }
+
+ string type = GetString(form.Payload.Metadata, "type", "uint16");
+ bool msbFirst = GetBool(form.Payload.Metadata, "mostSignificantByte", true);
+ bool mswFirst = GetBool(form.Payload.Metadata, "mostSignificantWord", true);
+ if (operation is
+ ModbusOperation.WriteSingleHoldingRegister or
+ ModbusOperation.WriteMultipleHoldingRegisters)
+ {
+ int encodedRegisterCount = ModbusDataTypes.RegisterCount(type);
+ if (quantity != encodedRegisterCount)
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus type '{type}' encodes {encodedRegisterCount} register(s), " +
+ $"which does not match quantity {quantity} for '{CanonicalMethod(operation)}'.",
+ nameof(form));
+ }
+ }
+
+ return new ModbusAddressing(
+ operation,
+ (ushort)address,
+ (ushort)quantity,
+ (byte)unitId,
+ type,
+ msbFirst,
+ mswFirst);
+ }
+
+ private static ModbusOperation ValidateOperation(
+ WotCompiledForm form,
+ ImmutableDictionary map,
+ string entity)
+ {
+ if (!TryResolveOperation(form.OperationInfo.Method, out ModbusOperation operation))
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus method '{form.OperationInfo.Method}' is not supported.",
+ nameof(form));
+ }
+ if (form.OperationInfo.Operation != form.Operation)
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus operation '{form.OperationInfo.Operation}' does not match " +
+ $"the form operation '{form.Operation}'.",
+ nameof(form));
+ }
+
+ bool writeOperation = IsWriteOperation(operation);
+ bool writeDirection = form.Operation == WoTBindingCapabilityEnum.WriteProperty;
+ bool readDirection = form.Operation is
+ WoTBindingCapabilityEnum.ReadProperty or WoTBindingCapabilityEnum.ObserveProperty;
+ if ((!writeDirection && !readDirection) || writeOperation != writeDirection)
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus method '{form.OperationInfo.Method}' is not valid for " +
+ $"the '{form.Operation}' operation.",
+ nameof(form));
+ }
+
+ string expectedEntity = ExpectedEntity(operation);
+ if (!string.Equals(entity, expectedEntity, StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus method '{form.OperationInfo.Method}' operates on " +
+ $"'{expectedEntity}', not '{entity}'.",
+ nameof(form));
+ }
+
+ if (map.TryGetValue("functionCode", out string? functionCodeText) &&
+ (!int.TryParse(
+ functionCodeText,
+ NumberStyles.Integer,
+ CultureInfo.InvariantCulture,
+ out int functionCode) ||
+ functionCode != (int)operation))
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus function code '{functionCodeText}' does not match " +
+ $"the method '{form.OperationInfo.Method}'.",
+ nameof(form));
+ }
+ if (map.TryGetValue("function", out string? functionName) &&
+ !string.Equals(functionName, CanonicalMethod(operation), StringComparison.OrdinalIgnoreCase))
+ {
+ throw new ArgumentException(
+ $"The compiled Modbus function '{functionName}' does not match " +
+ $"the method '{form.OperationInfo.Method}'.",
+ nameof(form));
+ }
+ return operation;
+ }
+
+ private static bool TryResolveOperation(string method, out ModbusOperation operation)
+ {
+ operation = method.ToLowerInvariant() switch
+ {
+ "readcoil" => ModbusOperation.ReadCoils,
+ "readdiscreteinput" => ModbusOperation.ReadDiscreteInputs,
+ "readholdingregisters" => ModbusOperation.ReadHoldingRegisters,
+ "readinputregister" => ModbusOperation.ReadInputRegisters,
+ "writesinglecoil" => ModbusOperation.WriteSingleCoil,
+ "writesingleholdingregister" => ModbusOperation.WriteSingleHoldingRegister,
+ "writemultiplecoils" => ModbusOperation.WriteMultipleCoils,
+ "writemultipleholdingregisters" => ModbusOperation.WriteMultipleHoldingRegisters,
+ _ => default
+ };
+ return operation != default &&
+ method.Equals(CanonicalMethod(operation), StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static string CanonicalMethod(ModbusOperation operation)
+ {
+ return operation switch
+ {
+ ModbusOperation.ReadCoils => "readCoil",
+ ModbusOperation.ReadDiscreteInputs => "readDiscreteInput",
+ ModbusOperation.ReadHoldingRegisters => "readHoldingRegisters",
+ ModbusOperation.ReadInputRegisters => "readInputRegister",
+ ModbusOperation.WriteSingleCoil => "writeSingleCoil",
+ ModbusOperation.WriteSingleHoldingRegister => "writeSingleHoldingRegister",
+ ModbusOperation.WriteMultipleCoils => "writeMultipleCoils",
+ ModbusOperation.WriteMultipleHoldingRegisters => "writeMultipleHoldingRegisters",
+ _ => string.Empty
+ };
+ }
+
+ private static string ExpectedEntity(ModbusOperation operation)
+ {
+ return operation switch
+ {
+ ModbusOperation.ReadCoils or
+ ModbusOperation.WriteSingleCoil or
+ ModbusOperation.WriteMultipleCoils => "coil",
+ ModbusOperation.ReadDiscreteInputs => "discreteInput",
+ ModbusOperation.ReadInputRegisters => "inputRegister",
+ _ => "holdingRegister"
+ };
+ }
+
+ private static int ProtocolMaximum(ModbusOperation operation)
+ {
+ return operation switch
+ {
+ ModbusOperation.ReadCoils or
+ ModbusOperation.ReadDiscreteInputs => ModbusProtocolLimits.MaxReadBits,
+ ModbusOperation.WriteMultipleCoils => ModbusProtocolLimits.MaxWriteCoils,
+ ModbusOperation.WriteMultipleHoldingRegisters => ModbusProtocolLimits.MaxWriteRegisters,
+ ModbusOperation.WriteSingleCoil or
+ ModbusOperation.WriteSingleHoldingRegister => 1,
+ _ => ModbusProtocolLimits.MaxReadRegisters
+ };
+ }
+
+ private static bool IsBitOperation(ModbusOperation operation)
+ {
+ return operation is
+ ModbusOperation.ReadCoils or
+ ModbusOperation.ReadDiscreteInputs or
+ ModbusOperation.WriteSingleCoil or
+ ModbusOperation.WriteMultipleCoils;
+ }
+
+ private static bool IsWriteOperation(ModbusOperation operation)
+ {
+ return operation is
+ ModbusOperation.WriteSingleCoil or
+ ModbusOperation.WriteSingleHoldingRegister or
+ ModbusOperation.WriteMultipleCoils or
+ ModbusOperation.WriteMultipleHoldingRegisters;
+ }
+
+ private static bool IsSingleWrite(ModbusOperation operation)
+ {
+ return operation is
+ ModbusOperation.WriteSingleCoil or
+ ModbusOperation.WriteSingleHoldingRegister;
+ }
+
+ private static string GetRequiredString(
+ ImmutableDictionary map,
+ string key,
+ WotCompiledForm form)
+ {
+ if (map.TryGetValue(key, out string? value) && !string.IsNullOrWhiteSpace(value))
+ {
+ return value;
+ }
+ throw new ArgumentException(
+ $"The compiled Modbus form requires '{key}' metadata.",
+ nameof(form));
+ }
+
+ private static int GetRequiredInt(
+ ImmutableDictionary map,
+ string key,
+ WotCompiledForm form)
+ {
+ if (map.TryGetValue(key, out string? value) &&
+ int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out int result))
+ {
+ return result;
+ }
+ throw new ArgumentException(
+ $"The compiled Modbus form requires integer '{key}' metadata.",
+ nameof(form));
+ }
+
+ private static string GetString(
+ ImmutableDictionary map,
+ string key,
+ string fallback)
+ {
+ return map.TryGetValue(key, out string? value) && !string.IsNullOrEmpty(value)
+ ? value
+ : fallback;
+ }
+
+ private static bool GetBool(ImmutableDictionary map, string key, bool fallback)
+ {
+ return map.TryGetValue(key, out string? value) && bool.TryParse(value, out bool result) ? result : fallback;
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs
new file mode 100644
index 0000000000..666115bd59
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusDataConverter.cs
@@ -0,0 +1,180 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// Converts between Modbus register words and OPC UA values, honouring the
+ /// modv:type data type and the byte / word order flags
+ /// (modv:mostSignificantByte / modv:mostSignificantWord).
+ ///
+ internal static class ModbusDataConverter
+ {
+ public static int RegisterCount(string type)
+ {
+ return ModbusDataTypes.RegisterCount(type);
+ }
+
+ public static Variant ToVariant(ushort[] registers, string type, bool msbFirst, bool mswFirst)
+ {
+ string normalized = ModbusDataTypes.Normalize(type);
+ int needed = RegisterCount(normalized);
+ if (registers.Length < needed)
+ {
+ throw new ModbusException(
+ $"The Modbus data type '{type}' requires {needed} registers but {registers.Length} were read.");
+ }
+ ushort[] slice = new ushort[needed];
+ Array.Copy(registers, slice, needed);
+ byte[] bigEndian = Canonical(slice, msbFirst, mswFirst);
+ byte[] host = ToHostOrder(bigEndian);
+ return normalized switch
+ {
+ "int16" => new Variant(BitConverter.ToInt16(host, 0)),
+ "uint16" => new Variant(BitConverter.ToUInt16(host, 0)),
+ "int32" => new Variant(BitConverter.ToInt32(host, 0)),
+ "uint32" => new Variant(BitConverter.ToUInt32(host, 0)),
+ "float32" => new Variant(BitConverter.ToSingle(host, 0)),
+ "int64" => new Variant(BitConverter.ToInt64(host, 0)),
+ "uint64" => new Variant(BitConverter.ToUInt64(host, 0)),
+ "float64" => new Variant(BitConverter.ToDouble(host, 0)),
+ _ => new Variant(BitConverter.ToUInt16(host, 0))
+ };
+ }
+
+ public static ushort[] ToRegisters(Variant value, string type, bool msbFirst, bool mswFirst)
+ {
+ string normalized = ModbusDataTypes.Normalize(type);
+ byte[] bigEndian = normalized switch
+ {
+ "int16" => BigEndianBytes(BitConverter.GetBytes(ToInt16(value))),
+ "uint16" => BigEndianBytes(BitConverter.GetBytes(ToUInt16(value))),
+ "int32" => BigEndianBytes(
+ BitConverter.GetBytes(Convert.ToInt32(BoxOf(value), CultureInfo.InvariantCulture))),
+ "uint32" => BigEndianBytes(
+ BitConverter.GetBytes(Convert.ToUInt32(BoxOf(value), CultureInfo.InvariantCulture))),
+ "float32" => BigEndianBytes(
+ BitConverter.GetBytes(Convert.ToSingle(BoxOf(value), CultureInfo.InvariantCulture))),
+ "int64" => BigEndianBytes(
+ BitConverter.GetBytes(Convert.ToInt64(BoxOf(value), CultureInfo.InvariantCulture))),
+ "uint64" => BigEndianBytes(
+ BitConverter.GetBytes(Convert.ToUInt64(BoxOf(value), CultureInfo.InvariantCulture))),
+ "float64" => BigEndianBytes(
+ BitConverter.GetBytes(Convert.ToDouble(BoxOf(value), CultureInfo.InvariantCulture))),
+ _ => BigEndianBytes(BitConverter.GetBytes(ToUInt16(value)))
+ };
+ return FromCanonical(bigEndian, msbFirst, mswFirst);
+ }
+
+ private static byte[] Canonical(ushort[] registers, bool msbFirst, bool mswFirst)
+ {
+ int words = registers.Length;
+ byte[][] wordBytes = new byte[words][];
+ for (int i = 0; i < words; i++)
+ {
+ byte hi = (byte)(registers[i] >> 8);
+ byte lo = (byte)(registers[i] & 0xFF);
+ wordBytes[i] = msbFirst ? [hi, lo] : [lo, hi];
+ }
+ if (!mswFirst)
+ {
+ Array.Reverse(wordBytes);
+ }
+ byte[] result = new byte[words * 2];
+ for (int i = 0; i < words; i++)
+ {
+ result[i * 2] = wordBytes[i][0];
+ result[(i * 2) + 1] = wordBytes[i][1];
+ }
+ return result;
+ }
+
+ private static ushort[] FromCanonical(byte[] bigEndian, bool msbFirst, bool mswFirst)
+ {
+ int words = bigEndian.Length / 2;
+ byte[][] wordBytes = new byte[words][];
+ for (int i = 0; i < words; i++)
+ {
+ wordBytes[i] = [bigEndian[i * 2], bigEndian[(i * 2) + 1]];
+ }
+ if (!mswFirst)
+ {
+ Array.Reverse(wordBytes);
+ }
+ ushort[] registers = new ushort[words];
+ for (int i = 0; i < words; i++)
+ {
+ byte b0 = wordBytes[i][0];
+ byte b1 = wordBytes[i][1];
+ byte hi = msbFirst ? b0 : b1;
+ byte lo = msbFirst ? b1 : b0;
+ registers[i] = (ushort)((hi << 8) | lo);
+ }
+ return registers;
+ }
+
+ private static byte[] ToHostOrder(byte[] bigEndian)
+ {
+ byte[] copy = (byte[])bigEndian.Clone();
+ if (BitConverter.IsLittleEndian)
+ {
+ Array.Reverse(copy);
+ }
+ return copy;
+ }
+
+ private static byte[] BigEndianBytes(byte[] hostOrder)
+ {
+ byte[] copy = (byte[])hostOrder.Clone();
+ if (BitConverter.IsLittleEndian)
+ {
+ Array.Reverse(copy);
+ }
+ return copy;
+ }
+
+ private static object BoxOf(Variant value)
+ {
+ return value.AsBoxedObject() ?? 0;
+ }
+
+ private static short ToInt16(Variant value)
+ {
+ return Convert.ToInt16(BoxOf(value), CultureInfo.InvariantCulture);
+ }
+
+ private static ushort ToUInt16(Variant value)
+ {
+ return Convert.ToUInt16(BoxOf(value), CultureInfo.InvariantCulture);
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs
new file mode 100644
index 0000000000..b6e8b72a6d
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusException.cs
@@ -0,0 +1,76 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// A Modbus protocol exception carrying the device exception code.
+ ///
+ public sealed class ModbusException : Exception
+ {
+ ///
+ /// Initializes a new Modbus exception.
+ ///
+ public ModbusException(byte exceptionCode, string message)
+ : base(message)
+ {
+ ExceptionCode = exceptionCode;
+ }
+
+ ///
+ /// Initializes a new Modbus exception without a device code.
+ ///
+ public ModbusException(string message)
+ : base(message)
+ {
+ }
+
+ ///
+ /// Initializes a new Modbus exception.
+ ///
+ public ModbusException()
+ {
+ }
+
+ ///
+ /// Initializes a new Modbus exception with an inner exception.
+ ///
+ public ModbusException(string message, Exception innerException)
+ : base(message, innerException)
+ {
+ }
+
+ ///
+ /// Gets the Modbus exception code, or 0 for a transport fault.
+ ///
+ public byte ExceptionCode { get; }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs
new file mode 100644
index 0000000000..eb8aaa6e5b
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusTcpClient.cs
@@ -0,0 +1,491 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// A minimal, robust Modbus TCP client sufficient for the WoT Modbus binding
+ /// forms: read coils / discrete inputs / holding registers / input registers
+ /// and write single / multiple coils and holding registers. It manages the
+ /// MBAP header, monotonically increasing transaction ids, request timeouts and
+ /// device exception decoding.
+ ///
+ public sealed class ModbusTcpClient : IDisposable
+ {
+ ///
+ /// Initializes a new Modbus TCP client.
+ ///
+ public ModbusTcpClient(string host, int port, TimeSpan timeout)
+ {
+ m_host = host ?? throw new ArgumentNullException(nameof(host));
+ m_port = port <= 0 ? 502 : port;
+ m_timeout = timeout <= TimeSpan.Zero ? TimeSpan.FromSeconds(10) : timeout;
+ }
+
+ ///
+ /// Connects (or reconnects) the underlying TCP socket. The connect is
+ /// serialized with in-flight transactions so a reconnect after a fault is
+ /// deterministic and thread-safe: any prior (possibly faulted) socket is
+ /// disposed first and a fresh connection replaces it atomically.
+ ///
+ public async ValueTask ConnectAsync(CancellationToken cancellationToken)
+ {
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(m_timeout);
+ await m_writeLock.WaitAsync(timeout.Token).ConfigureAwait(false);
+ try
+ {
+ await ReconnectCoreAsync(timeout.Token).ConfigureAwait(false);
+ }
+ finally
+ {
+ m_writeLock.Release();
+ }
+ }
+
+ ///
+ /// Reads holding registers (function code 3).
+ ///
+ public ValueTask ReadHoldingRegistersAsync(
+ byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken)
+ {
+ return ReadRegistersAsync(0x03, unitId, address, quantity, cancellationToken);
+ }
+
+ ///
+ /// Reads input registers (function code 4).
+ ///
+ public ValueTask ReadInputRegistersAsync(
+ byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken)
+ {
+ return ReadRegistersAsync(0x04, unitId, address, quantity, cancellationToken);
+ }
+
+ ///
+ /// Reads coils (function code 1).
+ ///
+ public ValueTask ReadCoilsAsync(
+ byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken)
+ {
+ return ReadBitsAsync(0x01, unitId, address, quantity, cancellationToken);
+ }
+
+ ///
+ /// Reads discrete inputs (function code 2).
+ ///
+ public ValueTask ReadDiscreteInputsAsync(
+ byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken)
+ {
+ return ReadBitsAsync(0x02, unitId, address, quantity, cancellationToken);
+ }
+
+ ///
+ /// Writes a single holding register (function code 6).
+ ///
+ public async ValueTask WriteSingleRegisterAsync(
+ byte unitId, ushort address, ushort value, CancellationToken cancellationToken)
+ {
+ byte[] pdu = [0x06, Hi(address), Lo(address), Hi(value), Lo(value)];
+ await TransactAsync(unitId, pdu, 0x06, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Writes multiple holding registers (function code 16).
+ ///
+ public async ValueTask WriteMultipleRegistersAsync(
+ byte unitId, ushort address, ushort[] values, CancellationToken cancellationToken)
+ {
+ if (values is null)
+ {
+ throw new ArgumentNullException(nameof(values));
+ }
+ int count = values.Length;
+ ValidateRegisterRange(address, count, ModbusProtocolLimits.MaxWriteRegisters, nameof(values));
+ byte byteCount = (byte)(count * 2);
+ byte[] pdu = new byte[6 + byteCount];
+ pdu[0] = 0x10;
+ pdu[1] = Hi(address);
+ pdu[2] = Lo(address);
+ pdu[3] = Hi((ushort)count);
+ pdu[4] = Lo((ushort)count);
+ pdu[5] = byteCount;
+ for (int i = 0; i < count; i++)
+ {
+ pdu[6 + (i * 2)] = Hi(values[i]);
+ pdu[7 + (i * 2)] = Lo(values[i]);
+ }
+ await TransactAsync(unitId, pdu, 0x10, cancellationToken).ConfigureAwait(false);
+ }
+
+ ///
+ /// Writes a single coil (function code 5).
+ ///
+ public async ValueTask WriteSingleCoilAsync(
+ byte unitId, ushort address, bool value, CancellationToken cancellationToken)
+ {
+ ushort encodedValue = value ? (ushort)0xFF00 : (ushort)0x0000;
+ byte[] pdu = [0x05, Hi(address), Lo(address), Hi(encodedValue), Lo(encodedValue)];
+ byte[] response = await TransactAsync(unitId, pdu, 0x05, cancellationToken).ConfigureAwait(false);
+ ValidateWriteAcknowledgement(response, address, encodedValue, "single-coil");
+ }
+
+ ///
+ /// Writes multiple coils (function code 15).
+ ///
+ public async ValueTask WriteMultipleCoilsAsync(
+ byte unitId, ushort address, bool[] values, CancellationToken cancellationToken)
+ {
+ if (values is null)
+ {
+ throw new ArgumentNullException(nameof(values));
+ }
+ int count = values.Length;
+ ValidateBitRange(address, count, ModbusProtocolLimits.MaxWriteCoils, nameof(values));
+ byte byteCount = (byte)((count + 7) / 8);
+ byte[] pdu = new byte[6 + byteCount];
+ pdu[0] = 0x0F;
+ pdu[1] = Hi(address);
+ pdu[2] = Lo(address);
+ pdu[3] = Hi((ushort)count);
+ pdu[4] = Lo((ushort)count);
+ pdu[5] = byteCount;
+ for (int i = 0; i < count; i++)
+ {
+ if (values[i])
+ {
+ pdu[6 + (i / 8)] |= (byte)(1 << (i % 8));
+ }
+ }
+ byte[] response = await TransactAsync(unitId, pdu, 0x0F, cancellationToken).ConfigureAwait(false);
+ ValidateWriteAcknowledgement(response, address, (ushort)count, "multiple-coil");
+ }
+
+ private static void ValidateWriteAcknowledgement(
+ byte[] response,
+ ushort address,
+ ushort value,
+ string operation)
+ {
+ if (response.Length != 5)
+ {
+ throw new ModbusException(
+ $"The Modbus {operation} acknowledgement must contain exactly 5 bytes.");
+ }
+ ushort echoedAddress = (ushort)((response[1] << 8) | response[2]);
+ if (echoedAddress != address)
+ {
+ throw new ModbusException(
+ $"The Modbus {operation} acknowledgement did not echo the requested address.");
+ }
+ ushort echoedValue = (ushort)((response[3] << 8) | response[4]);
+ if (echoedValue != value)
+ {
+ throw new ModbusException(
+ $"The Modbus {operation} acknowledgement did not echo the requested value or quantity.");
+ }
+ }
+
+ private static void ValidateBitRange(ushort address, int quantity, int maximum, string parameterName)
+ {
+ if (quantity is < 1 || quantity > maximum)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ quantity,
+ $"The Modbus bit quantity must be between 1 and {maximum}.");
+ }
+ if (address + quantity - 1 > ModbusProtocolLimits.MaxAddress)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ quantity,
+ $"The Modbus range starting at {address} for {quantity} bits exceeds the maximum " +
+ $"address {ModbusProtocolLimits.MaxAddress}.");
+ }
+ }
+
+ private static void ValidateRegisterRange(ushort address, int quantity, int maximum, string parameterName)
+ {
+ if (quantity is < 1 || quantity > maximum)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ quantity,
+ $"The Modbus register quantity must be between 1 and {maximum}.");
+ }
+ if (address + quantity - 1 > ModbusProtocolLimits.MaxAddress)
+ {
+ throw new ArgumentOutOfRangeException(
+ parameterName,
+ quantity,
+ $"The Modbus range starting at {address} for {quantity} registers exceeds the maximum " +
+ $"address {ModbusProtocolLimits.MaxAddress}.");
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ m_stream?.Dispose();
+ m_client?.Dispose();
+ m_writeLock.Dispose();
+ }
+
+ private async ValueTask ReadRegistersAsync(
+ byte function, byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken)
+ {
+ byte[] pdu = [function, Hi(address), Lo(address), Hi(quantity), Lo(quantity)];
+ byte[] response = await TransactAsync(unitId, pdu, function, cancellationToken).ConfigureAwait(false);
+ // response[0] is the (already validated) function code; response[1] is
+ // the byte count. Validate both the byte count and the overall length
+ // before indexing so a hostile or truncated frame cannot read out of
+ // bounds.
+ if (response.Length < 2)
+ {
+ throw new ModbusException("The Modbus register response is missing its byte count.");
+ }
+ int byteCount = response[1];
+ int expected = quantity * 2;
+ if (byteCount != expected || (byteCount & 1) != 0 || response.Length < 2 + byteCount)
+ {
+ throw new ModbusException(
+ "The Modbus register response byte count is inconsistent with the request.");
+ }
+ ushort[] registers = new ushort[byteCount / 2];
+ for (int i = 0; i < registers.Length; i++)
+ {
+ registers[i] = (ushort)((response[2 + (i * 2)] << 8) | response[3 + (i * 2)]);
+ }
+ return registers;
+ }
+
+ private async ValueTask ReadBitsAsync(
+ byte function, byte unitId, ushort address, ushort quantity, CancellationToken cancellationToken)
+ {
+ ValidateBitRange(address, quantity, ModbusProtocolLimits.MaxReadBits, nameof(quantity));
+ byte[] pdu = [function, Hi(address), Lo(address), Hi(quantity), Lo(quantity)];
+ byte[] response = await TransactAsync(unitId, pdu, function, cancellationToken).ConfigureAwait(false);
+ // response[1] is the packed-bit byte count. Validate it against the
+ // requested quantity and the frame length before indexing.
+ if (response.Length < 2)
+ {
+ throw new ModbusException("The Modbus bit response is missing its byte count.");
+ }
+ int byteCount = response[1];
+ int expected = (quantity + 7) / 8;
+ if (byteCount != expected || response.Length < 2 + byteCount)
+ {
+ throw new ModbusException(
+ "The Modbus bit response byte count is inconsistent with the request.");
+ }
+ bool[] bits = new bool[quantity];
+ for (int i = 0; i < quantity; i++)
+ {
+ int byteIndex = 2 + (i / 8);
+ bits[i] = (response[byteIndex] & (1 << (i % 8))) != 0;
+ }
+ return bits;
+ }
+
+ private async ValueTask TransactAsync(
+ byte unitId, byte[] pdu, byte expectedFunction, CancellationToken cancellationToken)
+ {
+ ushort transactionId = unchecked((ushort)Interlocked.Increment(ref m_transaction));
+ int length = pdu.Length + 1;
+ byte[] frame = new byte[7 + pdu.Length];
+ frame[0] = Hi(transactionId);
+ frame[1] = Lo(transactionId);
+ frame[2] = 0x00;
+ frame[3] = 0x00;
+ frame[4] = Hi((ushort)length);
+ frame[5] = Lo((ushort)length);
+ frame[6] = unitId;
+ Buffer.BlockCopy(pdu, 0, frame, 7, pdu.Length);
+
+ using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeout.CancelAfter(m_timeout);
+ await m_writeLock.WaitAsync(timeout.Token).ConfigureAwait(false);
+ try
+ {
+ if (m_stream is null && m_faulted)
+ {
+ await ReconnectCoreAsync(timeout.Token).ConfigureAwait(false);
+ }
+
+ NetworkStream? stream = m_stream ??
+ throw new ModbusException("The Modbus client is not connected.");
+
+ byte[] responsePdu;
+ try
+ {
+ await stream.WriteAsync(frame.AsMemory(), timeout.Token).ConfigureAwait(false);
+ await stream.FlushAsync(timeout.Token).ConfigureAwait(false);
+
+ byte[] header = await ReadExactAsync(stream, 7, timeout.Token).ConfigureAwait(false);
+ if (header[0] != Hi(transactionId) || header[1] != Lo(transactionId))
+ {
+ throw new ModbusException("The Modbus transaction id did not match.");
+ }
+ int responseLength = ((header[4] << 8) | header[5]) - 1;
+ if (responseLength < 1)
+ {
+ throw new ModbusException("The Modbus response length is invalid.");
+ }
+ responsePdu = await ReadExactAsync(stream, responseLength, timeout.Token).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (
+ ex is OperationCanceledException or System.IO.IOException or
+ SocketException or ObjectDisposedException or ModbusException)
+ {
+ // A timeout, cancellation, transport error, transaction-id
+ // mismatch or truncated/invalid frame leaves the stream in an
+ // unknown, desynchronized state. Fault the connection so the
+ // next operation establishes a fresh socket before sending.
+ FaultConnection();
+ throw;
+ }
+
+ // The response was framed by the MBAP length and read in full, so
+ // the stream stays synchronized: a device exception or an
+ // unexpected function code is a protocol result, not a desync, and
+ // must not fault the connection.
+ byte function = responsePdu[0];
+ if ((function & 0x80) != 0)
+ {
+ byte exceptionCode = responsePdu.Length > 1 ? responsePdu[1] : (byte)0;
+ throw new ModbusException(exceptionCode, DescribeException(exceptionCode));
+ }
+ if (function != expectedFunction)
+ {
+ throw new ModbusException(
+ $"Unexpected Modbus function 0x{function:X2} (expected 0x{expectedFunction:X2}).");
+ }
+ return responsePdu;
+ }
+ finally
+ {
+ m_writeLock.Release();
+ }
+ }
+
+ ///
+ /// Disposes and clears the current socket after a desynchronizing fault so
+ /// the next operation requires a fresh . Always
+ /// called while holding .
+ ///
+ private void FaultConnection()
+ {
+ m_faulted = true;
+ m_stream?.Dispose();
+ m_client?.Dispose();
+ m_stream = null;
+ m_client = null;
+ }
+
+ private async ValueTask ReconnectCoreAsync(CancellationToken cancellationToken)
+ {
+ // Dispose any prior (possibly faulted) connection so a reconnect
+ // always starts from a clean, deterministic state.
+ m_stream?.Dispose();
+ m_client?.Dispose();
+ m_stream = null;
+ m_client = null;
+
+ var client = new TcpClient { NoDelay = true };
+ try
+ {
+ await client.ConnectAsync(m_host, m_port, cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ client.Dispose();
+ throw;
+ }
+ m_client = client;
+ m_stream = client.GetStream();
+ m_faulted = false;
+ }
+
+ private static async ValueTask ReadExactAsync(
+ NetworkStream stream, int count, CancellationToken cancellationToken)
+ {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = await stream
+ .ReadAsync(buffer.AsMemory(offset, count - offset), cancellationToken)
+ .ConfigureAwait(false);
+ if (read == 0)
+ {
+ throw new ModbusException("The Modbus connection was closed by the peer.");
+ }
+ offset += read;
+ }
+ return buffer;
+ }
+
+ private static string DescribeException(byte code)
+ {
+ return code switch
+ {
+ 0x01 => "Illegal function.",
+ 0x02 => "Illegal data address.",
+ 0x03 => "Illegal data value.",
+ 0x04 => "Server device failure.",
+ 0x06 => "Server device busy.",
+ _ => $"Modbus exception 0x{code:X2}."
+ };
+ }
+
+ private static byte Hi(ushort value)
+ {
+ return (byte)(value >> 8);
+ }
+
+ private static byte Lo(ushort value)
+ {
+ return (byte)(value & 0xFF);
+ }
+
+ private readonly string m_host;
+ private readonly int m_port;
+ private readonly TimeSpan m_timeout;
+ private readonly SemaphoreSlim m_writeLock = new(1, 1);
+ private TcpClient? m_client;
+ private NetworkStream? m_stream;
+ private int m_transaction;
+ private bool m_faulted;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs
new file mode 100644
index 0000000000..e9a9fea8f4
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingChannel.cs
@@ -0,0 +1,322 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// A live Modbus TCP binding channel. It reads coils / discrete inputs /
+ /// holding / input registers and writes coils and holding registers with the
+ /// data type and byte / word order compiled from the form, mapping Modbus
+ /// exceptions and timeouts to OPC UA status codes.
+ ///
+ internal sealed class ModbusWotBindingChannel : IWotBindingChannel
+ {
+ public ModbusWotBindingChannel(
+ ModbusTcpClient client,
+ WotCompiledForm form,
+ WotExecutorContext context,
+ ModbusWotBindingOptions options,
+ ModbusAddressing addressing)
+ {
+ m_client = client;
+ Form = form;
+ m_context = context;
+ m_options = options;
+
+ m_operation = addressing.Operation;
+ m_address = addressing.Address;
+ m_quantity = addressing.Quantity;
+ m_unitId = addressing.UnitId;
+ m_type = addressing.Type;
+ m_msbFirst = addressing.MsbFirst;
+ m_mswFirst = addressing.MswFirst;
+ }
+
+ public WotCompiledForm Form { get; }
+
+ public async ValueTask ReadAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ Variant value;
+ if (m_operation == ModbusOperation.ReadCoils)
+ {
+ bool[] bits = await m_client
+ .ReadCoilsAsync(m_unitId, m_address, m_quantity, cancellationToken).ConfigureAwait(false);
+ value = ToBitVariant(bits);
+ }
+ else if (m_operation == ModbusOperation.ReadDiscreteInputs)
+ {
+ bool[] bits = await m_client
+ .ReadDiscreteInputsAsync(m_unitId, m_address, m_quantity, cancellationToken)
+ .ConfigureAwait(false);
+ value = ToBitVariant(bits);
+ }
+ else if (m_operation == ModbusOperation.ReadInputRegisters)
+ {
+ ushort[] regs = await m_client
+ .ReadInputRegistersAsync(m_unitId, m_address, m_quantity, cancellationToken)
+ .ConfigureAwait(false);
+ value = ModbusDataConverter.ToVariant(regs, m_type, m_msbFirst, m_mswFirst);
+ }
+ else if (m_operation == ModbusOperation.ReadHoldingRegisters)
+ {
+ ushort[] regs = await m_client
+ .ReadHoldingRegistersAsync(m_unitId, m_address, m_quantity, cancellationToken)
+ .ConfigureAwait(false);
+ value = ModbusDataConverter.ToVariant(regs, m_type, m_msbFirst, m_mswFirst);
+ }
+ else
+ {
+ return new WotReadResult(
+ StatusCodes.BadNotSupported,
+ DataValue.FromStatusCode(StatusCodes.BadNotSupported),
+ $"The Modbus operation '{m_operation}' is not readable.");
+ }
+ return new WotReadResult(
+ StatusCodes.Good, new DataValue(value, StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now));
+ }
+ catch (ModbusException ex)
+ {
+ StatusCode status = ModbusStatusMapper.Map(ex);
+ return new WotReadResult(status, DataValue.FromStatusCode(status), ex.Message);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return new WotReadResult(
+ StatusCodes.BadTimeout,
+ DataValue.FromStatusCode(StatusCodes.BadTimeout),
+ "The Modbus request timed out.");
+ }
+ catch (System.IO.IOException ex)
+ {
+ return new WotReadResult(
+ StatusCodes.BadCommunicationError,
+ DataValue.FromStatusCode(StatusCodes.BadCommunicationError), ex.Message);
+ }
+ catch (System.Net.Sockets.SocketException ex)
+ {
+ return new WotReadResult(
+ StatusCodes.BadCommunicationError,
+ DataValue.FromStatusCode(StatusCodes.BadCommunicationError), ex.Message);
+ }
+ }
+
+ public async ValueTask WriteAsync(
+ DataValue value, CancellationToken cancellationToken = default)
+ {
+ if (m_operation is
+ ModbusOperation.ReadCoils or
+ ModbusOperation.ReadDiscreteInputs or
+ ModbusOperation.ReadHoldingRegisters or
+ ModbusOperation.ReadInputRegisters)
+ {
+ return new WotWriteResult(StatusCodes.BadNotWritable, "The Modbus operation is read-only.");
+ }
+ try
+ {
+ if (m_operation == ModbusOperation.WriteSingleCoil)
+ {
+ if (!value.WrappedValue.TryGetValue(out bool on))
+ {
+ return new WotWriteResult(
+ StatusCodes.BadTypeMismatch,
+ "The Modbus single-coil write requires a Boolean scalar.");
+ }
+ await m_client.WriteSingleCoilAsync(m_unitId, m_address, on, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else if (m_operation == ModbusOperation.WriteMultipleCoils)
+ {
+ bool[] coilValues;
+ if (m_quantity == 1)
+ {
+ if (!value.WrappedValue.TryGetValue(out bool on))
+ {
+ return new WotWriteResult(
+ StatusCodes.BadTypeMismatch,
+ "The Modbus multiple-coil write with quantity 1 requires a Boolean scalar.");
+ }
+ coilValues = [on];
+ }
+ else if (value.WrappedValue.TryGetValue(out ArrayOf bits))
+ {
+ if (bits.Count != m_quantity)
+ {
+ return new WotWriteResult(
+ StatusCodes.BadInvalidArgument,
+ $"The Modbus multiple-coil write requires exactly {m_quantity} Boolean values; " +
+ $"the payload contains {bits.Count}.");
+ }
+ coilValues = bits.Memory.ToArray();
+ }
+ else
+ {
+ return new WotWriteResult(
+ StatusCodes.BadTypeMismatch,
+ $"The Modbus multiple-coil write requires an array of {m_quantity} Boolean values.");
+ }
+ await m_client
+ .WriteMultipleCoilsAsync(
+ m_unitId, m_address, coilValues, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else if (m_operation is
+ ModbusOperation.WriteSingleHoldingRegister or
+ ModbusOperation.WriteMultipleHoldingRegisters)
+ {
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ value.WrappedValue, m_type, m_msbFirst, m_mswFirst);
+ if (m_operation == ModbusOperation.WriteSingleHoldingRegister)
+ {
+ if (registers.Length != 1)
+ {
+ return new WotWriteResult(
+ StatusCodes.BadTypeMismatch,
+ "The Modbus single-register write requires a value encoded in one register.");
+ }
+ await m_client
+ .WriteSingleRegisterAsync(m_unitId, m_address, registers[0], cancellationToken)
+ .ConfigureAwait(false);
+ }
+ else
+ {
+ if (registers.Length != m_quantity)
+ {
+ return new WotWriteResult(
+ StatusCodes.BadInvalidArgument,
+ $"The Modbus multiple-register write requires exactly {m_quantity} registers; " +
+ $"the payload encodes {registers.Length}.");
+ }
+ await m_client
+ .WriteMultipleRegistersAsync(m_unitId, m_address, registers, cancellationToken)
+ .ConfigureAwait(false);
+ }
+ }
+ return new WotWriteResult(StatusCodes.Good);
+ }
+ catch (ModbusException ex)
+ {
+ return new WotWriteResult(ModbusStatusMapper.Map(ex), ex.Message);
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return new WotWriteResult(StatusCodes.BadTimeout, "The Modbus request timed out.");
+ }
+ catch (System.IO.IOException ex)
+ {
+ return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message);
+ }
+ catch (System.Net.Sockets.SocketException ex)
+ {
+ return new WotWriteResult(StatusCodes.BadCommunicationError, ex.Message);
+ }
+ catch (Exception ex) when (
+ ex is FormatException or InvalidCastException or OverflowException)
+ {
+ return new WotWriteResult(StatusCodes.BadTypeMismatch, ex.Message);
+ }
+ }
+
+ public ValueTask InvokeAsync(
+ IReadOnlyList inputs, CancellationToken cancellationToken = default)
+ {
+ return new ValueTask(new WotInvokeResult(
+ StatusCodes.BadNotSupported, null, "Modbus does not support action invocation."));
+ }
+
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "Ownership of the subscription is transferred to the caller, who disposes it.")]
+ public ValueTask ObserveAsync(
+ Action onNotification, CancellationToken cancellationToken = default)
+ {
+ if (onNotification is null)
+ {
+ throw new ArgumentNullException(nameof(onNotification));
+ }
+ var subscription = new PollingWotSubscription(
+ Form,
+ async token =>
+ {
+ WotReadResult result = await ReadAsync(token).ConfigureAwait(false);
+ // A mapped failure carries its bad StatusCode on the value, so surface it
+ // rather than leaving the last good value in place, and report the poll as
+ // unhealthy so the retry policy backs off instead of hammering the asset.
+ onNotification(new WotNotification(result.Value));
+ return result.Success;
+ },
+ // A form that declares the standard modv:pollingTime wins over the executor's
+ // configured default, so a per-affordance rate is honoured.
+ Form.OperationInfo.PollInterval ?? m_options.ObserveInterval,
+ // A transient poll fault is reported as a Bad-status notification
+ // so consumers observe the fault without the poll loop faulting.
+ onError: _ => onNotification(new WotNotification(
+ DataValue.FromStatusCode(StatusCodes.BadCommunicationError))),
+ retryPolicy: m_options.RetryPolicy,
+ telemetry: m_context.Telemetry);
+ return new ValueTask(subscription);
+ }
+
+ public ValueTask SubscribeEventAsync(
+ Action onEvent, CancellationToken cancellationToken = default)
+ {
+ return ObserveAsync(onEvent, cancellationToken);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ m_client.Dispose();
+ return default;
+ }
+
+ private Variant ToBitVariant(bool[] bits)
+ {
+ return m_quantity == 1
+ ? new Variant(bits[0])
+ : new Variant((ArrayOf)bits);
+ }
+
+ private readonly ModbusTcpClient m_client;
+ private readonly WotExecutorContext m_context;
+ private readonly ModbusWotBindingOptions m_options;
+ private readonly ModbusOperation m_operation;
+ private readonly ushort m_address;
+ private readonly ushort m_quantity;
+ private readonly byte m_unitId;
+ private readonly string m_type;
+ private readonly bool m_msbFirst;
+ private readonly bool m_mswFirst;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs
new file mode 100644
index 0000000000..41205e5968
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingExecutor.cs
@@ -0,0 +1,98 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// Executes Modbus TCP WoT binding forms compiled by the
+ /// by opening a per-form Modbus TCP
+ /// connection.
+ ///
+ public sealed class ModbusWotBindingExecutor : IWotBindingExecutor
+ {
+ ///
+ /// Initializes a new Modbus executor.
+ ///
+ public ModbusWotBindingExecutor(ModbusWotBindingOptions? options = null)
+ {
+ m_options = options ?? new ModbusWotBindingOptions();
+ }
+
+ ///
+ public WotBindingIdentity Identity { get; } =
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri, "W3C WoT Modbus Executor");
+
+ ///
+ public bool CanExecute(WotCompiledForm form)
+ {
+ return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal);
+ }
+
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "The client is owned by the returned channel, which disposes it.")]
+ public async ValueTask ActivateAsync(
+ WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default)
+ {
+ if (form is null)
+ {
+ throw new ArgumentNullException(nameof(form));
+ }
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+ // Re-validate the addressing (and perform the ushort / byte casts) before
+ // opening the socket so a hand-built or tampered compiled form fails fast
+ // and never leaks a half-open connection.
+ var addressing = ModbusAddressing.FromForm(form, context.Bounds);
+ string host = string.IsNullOrEmpty(form.Endpoint.Host) ? "127.0.0.1" : form.Endpoint.Host!;
+ int port = form.Endpoint.Port > 0 ? form.Endpoint.Port : 502;
+ var client = new ModbusTcpClient(host, port, context.Bounds.DefaultTimeout);
+ try
+ {
+ await client.ConnectAsync(cancellationToken).ConfigureAwait(false);
+ }
+ catch
+ {
+ client.Dispose();
+ throw;
+ }
+ return new ModbusWotBindingChannel(client, form, context, m_options, addressing);
+ }
+
+ private readonly ModbusWotBindingOptions m_options;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs
new file mode 100644
index 0000000000..e2720044cb
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/ModbusWotBindingOptions.cs
@@ -0,0 +1,72 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+
+namespace Opc.Ua.WotCon.Bindings.Modbus
+{
+ ///
+ /// Options for the Modbus TCP WoT binding executor.
+ ///
+ public sealed class ModbusWotBindingOptions
+ {
+ ///
+ /// Gets or sets the poll interval used for observe operations.
+ ///
+ public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the retry policy applied after consecutive unhealthy polls during an
+ /// observe operation. Defaults to the stack's exponential backoff
+ /// (, 500 ms doubling to 30 s), so an
+ /// asset that has gone offline is not polled once per interval. The backoff never polls
+ /// faster than .
+ ///
+ public IChannelReconnectPolicy? RetryPolicy { get; set; }
+ }
+
+ ///
+ /// Maps Modbus device exception codes to OPC UA status codes.
+ ///
+ internal static class ModbusStatusMapper
+ {
+ public static StatusCode Map(ModbusException exception)
+ {
+ return exception.ExceptionCode switch
+ {
+ 0x01 => StatusCodes.BadNotSupported,
+ 0x02 => StatusCodes.BadNodeIdUnknown,
+ 0x03 => StatusCodes.BadInvalidArgument,
+ 0x04 => StatusCodes.BadInternalError,
+ 0x06 => StatusCodes.BadResourceUnavailable,
+ _ => StatusCodes.BadCommunicationError
+ };
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs
new file mode 100644
index 0000000000..80ef357bbc
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/Modbus/OpcUaModbusWotBindingBuilderExtensions.cs
@@ -0,0 +1,61 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.WotCon.Bindings.Modbus;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// extensions that register the Modbus TCP WoT
+ /// binding executor alongside the shipped planner binders.
+ ///
+ public static class OpcUaModbusWotBindingBuilderExtensions
+ {
+ ///
+ /// Registers the eight shipped planner binders and the Modbus TCP executor,
+ /// so Modbus binding forms are validated, compiled and executable.
+ ///
+ ///
+ public static IOpcUaBuilder AddModbusWotBinding(
+ this IOpcUaBuilder builder, Action? configure = null)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ var options = new ModbusWotBindingOptions();
+ configure?.Invoke(options);
+ return builder
+ .AddWotProtocolBinders()
+ .AddWotBindingExecutor(new ModbusWotBindingExecutor(options));
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs
new file mode 100644
index 0000000000..15ae8405b8
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaTargetWotBindingBuilderExtensions.cs
@@ -0,0 +1,65 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using Opc.Ua;
+using Opc.Ua.WotCon.Bindings.OpcUa;
+
+namespace Microsoft.Extensions.DependencyInjection
+{
+ ///
+ /// extensions that register the OPC UA WoT binding
+ /// executor (OPC UA-to-OPC UA translation) alongside the shipped planner binders.
+ ///
+ public static class OpcUaTargetWotBindingBuilderExtensions
+ {
+ ///
+ /// Registers the eight shipped planner binders and the OPC UA executor, so
+ /// OPC UA binding forms are validated, compiled and executable.
+ ///
+ ///
+ public static IOpcUaBuilder AddOpcUaWotBinding(
+ this IOpcUaBuilder builder, Action configure)
+ {
+ if (builder is null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (configure is null)
+ {
+ throw new ArgumentNullException(nameof(configure));
+ }
+ var options = new OpcUaWotBindingOptions();
+ configure(options);
+ return builder
+ .AddWotProtocolBinders()
+ .AddWotBindingExecutor(new OpcUaWotBindingExecutor(options));
+ }
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs
new file mode 100644
index 0000000000..e18ca4daec
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingChannel.cs
@@ -0,0 +1,553 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Client;
+using WellKnownObjectTypeIds = Opc.Ua.Types.ObjectTypeIds;
+
+namespace Opc.Ua.WotCon.Bindings.OpcUa
+{
+ ///
+ /// A live OPC UA binding channel that translates WoT operations onto OPC UA
+ /// services: read / write of a NodeId Value attribute, observe and event
+ /// subscription via a native /
+ /// pair (Part 4 §5.12 / §5.13), and action invocation via Method Call
+ /// preserving argument order and /
+ /// metadata.
+ ///
+ internal sealed class OpcUaWotBindingChannel : IWotBindingChannel
+ {
+ public OpcUaWotBindingChannel(
+ ISession session,
+ bool disposeSession,
+ WotCompiledForm form,
+ WotExecutorContext context,
+ OpcUaWotBindingOptions options)
+ {
+ m_session = session;
+ m_disposeSession = disposeSession;
+ Form = form;
+ m_options = options;
+ m_nodeId = form.Addressing.Target;
+ }
+
+ public WotCompiledForm Form { get; }
+
+ public async ValueTask ReadAsync(CancellationToken cancellationToken = default)
+ {
+ if (!TryResolveNodeId(m_nodeId, out NodeId nodeId))
+ {
+ return new WotReadResult(
+ StatusCodes.BadNodeIdInvalid,
+ DataValue.FromStatusCode(StatusCodes.BadNodeIdInvalid),
+ $"'{m_nodeId}' is not a valid NodeId.");
+ }
+ try
+ {
+ DataValue value = await m_session.ReadValueAsync(nodeId, cancellationToken).ConfigureAwait(false);
+ return new WotReadResult(value.StatusCode, value);
+ }
+ catch (ServiceResultException ex)
+ {
+ StatusCode status = ex.StatusCode;
+ return new WotReadResult(status, DataValue.FromStatusCode(status), ex.Message);
+ }
+ }
+
+ public async ValueTask WriteAsync(
+ DataValue value, CancellationToken cancellationToken = default)
+ {
+ if (!TryResolveNodeId(m_nodeId, out NodeId nodeId))
+ {
+ return new WotWriteResult(StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid NodeId.");
+ }
+ try
+ {
+ var write = new WriteValue
+ {
+ NodeId = nodeId,
+ AttributeId = Attributes.Value,
+ Value = new DataValue(value.WrappedValue)
+ };
+ WriteResponse response = await m_session
+ .WriteAsync(null, new WriteValue[] { write }, cancellationToken).ConfigureAwait(false);
+ StatusCode status = response.Results is { Count: > 0 }
+ ? response.Results[0] : StatusCodes.BadUnexpectedError;
+ return new WotWriteResult(status, StatusCode.IsBad(status) ? status.ToString() : null);
+ }
+ catch (ServiceResultException ex)
+ {
+ return new WotWriteResult(ex.StatusCode, ex.Message);
+ }
+ }
+
+ public async ValueTask InvokeAsync(
+ IReadOnlyList inputs, CancellationToken cancellationToken = default)
+ {
+ if (!Form.Addressing.Metadata.TryGetValue("componentOf", out string? objectRef) ||
+ string.IsNullOrEmpty(objectRef) ||
+ !TryResolveNodeId(objectRef!, out NodeId objectId))
+ {
+ return new WotInvokeResult(
+ StatusCodes.BadNodeIdInvalid, null,
+ "An OPC UA action requires a uav:componentOf object NodeId.");
+ }
+ if (!TryResolveNodeId(m_nodeId, out NodeId methodId))
+ {
+ return new WotInvokeResult(
+ StatusCodes.BadNodeIdInvalid, null, $"'{m_nodeId}' is not a valid method NodeId.");
+ }
+ try
+ {
+ Variant[] arguments = inputs is null ? [] : [.. inputs];
+ ArrayOf outputs = await m_session
+ .CallAsync(objectId, methodId, cancellationToken, arguments).ConfigureAwait(false);
+ var results = new DataValue[outputs.Count];
+ for (int i = 0; i < outputs.Count; i++)
+ {
+ results[i] = new DataValue(outputs[i], StatusCodes.Good, DateTimeUtc.Now, DateTimeUtc.Now);
+ }
+ return new WotInvokeResult(StatusCodes.Good, results);
+ }
+ catch (ServiceResultException ex)
+ {
+ return new WotInvokeResult(ex.StatusCode, null, ex.Message);
+ }
+ }
+
+ public ValueTask ObserveAsync(
+ Action onNotification, CancellationToken cancellationToken = default)
+ {
+ if (onNotification is null)
+ {
+ throw new ArgumentNullException(nameof(onNotification));
+ }
+ if (!TryResolveNodeId(m_nodeId, out NodeId nodeId))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid NodeId.");
+ }
+ // Native data-change subscription: the server samples and reports
+ // changes (Part 4 §5.12); no client-side polling is involved.
+ return CreateMonitoredSubscriptionAsync(
+ nodeId,
+ NodeClass.Variable,
+ Attributes.Value,
+ filter: null,
+ queueSize: 1,
+ translate: static (_, notificationValue) => notificationValue is MonitoredItemNotification change
+ ? new WotNotification(change.Value)
+ : null,
+ onNotification,
+ cancellationToken);
+ }
+
+ public ValueTask SubscribeEventAsync(
+ Action onEvent, CancellationToken cancellationToken = default)
+ {
+ if (onEvent is null)
+ {
+ throw new ArgumentNullException(nameof(onEvent));
+ }
+ if (!TryResolveNodeId(m_nodeId, out NodeId notifierId))
+ {
+ throw new ServiceResultException(
+ StatusCodes.BadNodeIdInvalid, $"'{m_nodeId}' is not a valid event notifier NodeId.");
+ }
+ EventFilter filter = BuildEventFilter();
+ return CreateMonitoredSubscriptionAsync(
+ notifierId,
+ NodeClass.Object,
+ Attributes.EventNotifier,
+ filter,
+ queueSize: m_options.EventQueueSize,
+ translate: (_, notificationValue) => notificationValue is EventFieldList eventFields
+ ? BuildEventNotification(filter, eventFields)
+ : null,
+ onEvent,
+ cancellationToken);
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ if (m_disposeSession)
+ {
+ m_session.Dispose();
+ }
+ return default;
+ }
+
+ ///
+ /// Opens a native OPC UA with a single
+ /// , translates each notification through
+ /// and forwards it to .
+ /// Ownership of the created subscription (and its server-side
+ /// resources) transfers to the returned ;
+ /// on any failure to create/apply, the subscription is torn down and
+ /// removed from the session before the exception propagates, so no
+ /// session/subscription is leaked.
+ ///
+ [System.Diagnostics.CodeAnalysis.SuppressMessage(
+ "Reliability", "CA2000:Dispose objects before losing scope",
+ Justification = "Ownership of the subscription is transferred to the caller (an " +
+ "IWotSubscription), who disposes it; on failure it is disposed in the catch block.")]
+ private async ValueTask CreateMonitoredSubscriptionAsync(
+ NodeId targetId,
+ NodeClass nodeClass,
+ uint attributeId,
+ MonitoringFilter? filter,
+ uint queueSize,
+ Func translate,
+ Action onNotification,
+ CancellationToken cancellationToken)
+ {
+ int interval = NormalizeInterval(m_options.ObserveInterval);
+ var subscription = new Subscription(m_session.DefaultSubscription)
+ {
+ DisplayName = "wot-" + Form.AffordanceName,
+ PublishingEnabled = true,
+ PublishingInterval = interval
+ };
+ m_session.AddSubscription(subscription);
+ try
+ {
+ await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
+
+ var item = new MonitoredItem(subscription.DefaultItem)
+ {
+ StartNodeId = targetId,
+ NodeClass = nodeClass,
+ AttributeId = attributeId,
+ DisplayName = Form.AffordanceName,
+ SamplingInterval = interval,
+ QueueSize = queueSize,
+ DiscardOldest = true
+ };
+ if (filter is not null)
+ {
+ item.Filter = filter;
+ }
+
+ void OnItemNotification(MonitoredItem monitoredItem, MonitoredItemNotificationEventArgs e)
+ {
+ WotNotification? notification = translate(monitoredItem, e.NotificationValue);
+ if (notification is not null)
+ {
+ onNotification(notification);
+ }
+ }
+ item.Notification += OnItemNotification;
+
+ subscription.AddItem(item);
+ await subscription.ApplyChangesAsync(cancellationToken).ConfigureAwait(false);
+
+ if (!item.Created)
+ {
+ item.Notification -= OnItemNotification;
+ StatusCode status = item.Status.Error?.StatusCode ?? StatusCodes.BadMonitoredItemFilterUnsupported;
+ throw new ServiceResultException(
+ status,
+ item.Status.Error?.ToString() ?? "The server rejected the monitored item.");
+ }
+
+ return new OpcUaMonitoredItemSubscription(Form, m_session, subscription, item, OnItemNotification);
+ }
+ catch
+ {
+ await RemoveSubscriptionSafeAsync(subscription).ConfigureAwait(false);
+ throw;
+ }
+ }
+
+ private async ValueTask RemoveSubscriptionSafeAsync(Subscription subscription)
+ {
+ try
+ {
+ await m_session.RemoveSubscriptionAsync(subscription, CancellationToken.None).ConfigureAwait(false);
+ }
+ catch (ServiceResultException)
+ {
+ // Best-effort server-side cleanup; the session or subscription
+ // may already be unusable (for example a closed session).
+ }
+ subscription.Dispose();
+ }
+
+ ///
+ /// Builds the event select filter: ,
+ /// , ,
+ /// , ,
+ /// ,
+ /// and , plus any binding-authored
+ /// uav:eventFields select clauses carried by the compiled form.
+ ///
+ private EventFilter BuildEventFilter()
+ {
+ var filter = new EventFilter();
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.EventId));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.EventType));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.SourceNode));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.SourceName));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Time));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.ReceiveTime));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Message));
+ filter.AddSelectClause(
+ WellKnownObjectTypeIds.BaseEventType, QualifiedName.From(EventBrowseNames.Severity));
+
+ if (Form.Addressing.Metadata.TryGetValue("eventFields", out string? extra) &&
+ !string.IsNullOrEmpty(extra))
+ {
+ var seen = new HashSet(StringComparer.Ordinal)
+ {
+ EventBrowseNames.EventId, EventBrowseNames.EventType, EventBrowseNames.SourceNode,
+ EventBrowseNames.SourceName, EventBrowseNames.Time, EventBrowseNames.ReceiveTime,
+ EventBrowseNames.Message, EventBrowseNames.Severity
+ };
+ foreach (string field in extra.Split('|', StringSplitOptions.RemoveEmptyEntries))
+ {
+ if (seen.Add(field))
+ {
+ filter.AddSelectClause(WellKnownObjectTypeIds.BaseEventType, field, Attributes.Value);
+ }
+ }
+ }
+ return filter;
+ }
+
+ ///
+ /// Projects a raw notification into a
+ /// deterministically: every select-clause
+ /// field is captured in keyed
+ /// by its browse path, each carrying the event's own Time / ReceiveTime
+ /// as its source / server timestamp so no timestamp is lost. The
+ /// notification's primary wraps the Message
+ /// field (or the first field, if Message was not selected) with a
+ /// status.
+ ///
+ private static WotNotification BuildEventNotification(EventFilter filter, EventFieldList eventFields)
+ {
+ ArrayOf values = eventFields.EventFields;
+ int count = Math.Min(filter.SelectClauses.Count, values.Count);
+
+ DateTimeUtc sourceTimestamp = DateTimeUtc.Now;
+ DateTimeUtc serverTimestamp = DateTimeUtc.Now;
+ for (int i = 0; i < count; i++)
+ {
+ string name = FormatFieldName(filter.SelectClauses[i]);
+ if (string.Equals(name, EventBrowseNames.Time, StringComparison.Ordinal) &&
+ values[i].TryGetValue(out DateTimeUtc time))
+ {
+ sourceTimestamp = time;
+ }
+ else if (string.Equals(name, EventBrowseNames.ReceiveTime, StringComparison.Ordinal) &&
+ values[i].TryGetValue(out DateTimeUtc receiveTime))
+ {
+ serverTimestamp = receiveTime;
+ }
+ }
+
+ var fields = new Dictionary(count, StringComparer.Ordinal);
+ Variant primary = Variant.Null;
+ bool havePrimary = false;
+ for (int i = 0; i < count; i++)
+ {
+ string name = FormatFieldName(filter.SelectClauses[i]);
+ Variant fieldValue = values[i];
+ fields[name] = new DataValue(fieldValue, StatusCodes.Good, sourceTimestamp, serverTimestamp);
+ if (string.Equals(name, EventBrowseNames.Message, StringComparison.Ordinal))
+ {
+ primary = fieldValue;
+ havePrimary = true;
+ }
+ }
+ // Defensive: BuildEventFilter always adds the Message select clause
+ // and is the only filter BuildEventNotification is ever called with,
+ // so a primary value is always found. The fallback keeps the
+ // notification meaningful if that filter ever stops selecting
+ // Message.
+ if (!havePrimary && count > 0)
+ {
+ primary = values[0];
+ }
+
+ var dataValue = new DataValue(primary, StatusCodes.Good, sourceTimestamp, serverTimestamp);
+ return new WotNotification(dataValue, fields);
+ }
+
+ ///
+ /// Formats a select-clause browse path without its leading separator.
+ ///
+ private static string FormatFieldName(SimpleAttributeOperand clause)
+ {
+ string formatted = SimpleAttributeOperand.Format(clause.BrowsePath);
+ return formatted.Length > 0 && formatted[0] == '/' ? formatted[1..] : formatted;
+ }
+
+ ///
+ /// The mandatory BaseEventType browse names (Part 5 §6.4.2) used to
+ /// build the baseline event select filter. These are stable OPC UA
+ /// browse names, so they are declared locally rather than depending on
+ /// a per-project generated identifier set.
+ ///
+ private static class EventBrowseNames
+ {
+ public const string EventId = "EventId";
+ public const string EventType = "EventType";
+ public const string SourceNode = "SourceNode";
+ public const string SourceName = "SourceName";
+ public const string Time = "Time";
+ public const string ReceiveTime = "ReceiveTime";
+ public const string Message = "Message";
+ public const string Severity = "Severity";
+ }
+
+ ///
+ /// Normalizes an observe interval into a bounded millisecond publishing/sampling interval.
+ ///
+ private static int NormalizeInterval(TimeSpan interval)
+ {
+ double ms = interval.TotalMilliseconds;
+ return ms > 100.0 ? (int)ms : 100;
+ }
+
+ ///
+ /// Resolves a compiled-form NodeId string to a local .
+ /// Plain ns= / i= / s= / g= / b= forms
+ /// resolve without a session round-trip. A portable NodeId carrying an
+ /// nsu= namespace URI (Part 6 §5.3.1.11) cannot be resolved by
+ /// alone (it always fails for that
+ /// form), so it is parsed as an and
+ /// resolved against the connected session's namespace table.
+ ///
+ private bool TryResolveNodeId(string value, out NodeId nodeId)
+ {
+ if (TryParseNodeId(value, out nodeId))
+ {
+ return true;
+ }
+ if (!ExpandedNodeId.TryParse(value, out ExpandedNodeId expanded) || expanded.IsNull)
+ {
+ nodeId = NodeId.Null;
+ return false;
+ }
+ nodeId = ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris);
+ return !nodeId.IsNull;
+ }
+
+ private static bool TryParseNodeId(string value, out NodeId nodeId)
+ {
+ try
+ {
+ nodeId = NodeId.Parse(value);
+ return !nodeId.IsNull;
+ }
+ catch (ServiceResultException)
+ {
+ nodeId = NodeId.Null;
+ return false;
+ }
+ catch (FormatException)
+ {
+ nodeId = NodeId.Null;
+ return false;
+ }
+ catch (ArgumentException)
+ {
+ // NodeId.Parse throws ArgumentException (not ServiceResultException)
+ // for a portable "nsu=" / missing-identifier form; treat it the
+ // same as any other unparseable text so TryResolveNodeId can fall
+ // back to ExpandedNodeId + namespace table resolution.
+ nodeId = NodeId.Null;
+ return false;
+ }
+ }
+
+ ///
+ /// A running native OPC UA subscription backing an observe or event
+ /// channel. Disposing it removes the monitored item's notification
+ /// handler and deletes the subscription server-side (via
+ /// ) before releasing the
+ /// local , so no session/subscription leaks.
+ ///
+ private sealed class OpcUaMonitoredItemSubscription : IWotSubscription
+ {
+ public OpcUaMonitoredItemSubscription(
+ WotCompiledForm form,
+ ISession session,
+ Subscription subscription,
+ MonitoredItem item,
+ MonitoredItemNotificationEventHandler handler)
+ {
+ Form = form;
+ m_session = session;
+ m_subscription = subscription;
+ m_item = item;
+ m_handler = handler;
+ }
+
+ public WotCompiledForm Form { get; }
+
+ public async ValueTask DisposeAsync()
+ {
+ m_item.Notification -= m_handler;
+ try
+ {
+ await m_session.RemoveSubscriptionAsync(m_subscription, CancellationToken.None)
+ .ConfigureAwait(false);
+ }
+ catch (ServiceResultException)
+ {
+ // Best-effort server-side cleanup; the session may already
+ // be closed or the subscription already removed.
+ }
+ m_subscription.Dispose();
+ }
+
+ private readonly ISession m_session;
+ private readonly Subscription m_subscription;
+ private readonly MonitoredItem m_item;
+ private readonly MonitoredItemNotificationEventHandler m_handler;
+ }
+
+ private readonly ISession m_session;
+ private readonly bool m_disposeSession;
+ private readonly OpcUaWotBindingOptions m_options;
+ private readonly string m_nodeId;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs
new file mode 100644
index 0000000000..371371aa75
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingExecutor.cs
@@ -0,0 +1,128 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Client;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.OpcUa
+{
+ ///
+ /// Executes OPC UA WoT binding forms compiled by the
+ /// by connecting an to
+ /// the target endpoint through the injectable session factory.
+ ///
+ public sealed class OpcUaWotBindingExecutor : IWotBindingExecutor
+ {
+ ///
+ /// Initializes a new OPC UA executor.
+ ///
+ public OpcUaWotBindingExecutor(OpcUaWotBindingOptions options)
+ {
+ m_options = options ?? throw new ArgumentNullException(nameof(options));
+ }
+
+ ///
+ public WotBindingIdentity Identity { get; } =
+ new WotBindingIdentity("opc.opcua", "10101", OpcUaBindingPlanner.BindingUri, "OPC UA WoT Executor");
+
+ ///
+ public bool CanExecute(WotCompiledForm form)
+ {
+ return form is not null && string.Equals(form.Binding.Id, Identity.Id, StringComparison.Ordinal);
+ }
+
+ ///
+ public async ValueTask ActivateAsync(
+ WotCompiledForm form, WotExecutorContext context, CancellationToken cancellationToken = default)
+ {
+ if (form is null)
+ {
+ throw new ArgumentNullException(nameof(form));
+ }
+ if (context is null)
+ {
+ throw new ArgumentNullException(nameof(context));
+ }
+ if (m_options.SessionFactory is null)
+ {
+ throw new InvalidOperationException(
+ "No OPC UA session factory is configured on the executor options.");
+ }
+ string endpoint = BuildEndpoint(form.Endpoint);
+ ISession session = await m_options.SessionFactory(endpoint, cancellationToken).ConfigureAwait(false);
+ return new OpcUaWotBindingChannel(session, m_options.DisposeSession, form, context, m_options);
+ }
+
+ private static string BuildEndpoint(WotEndpointDescriptor endpoint)
+ {
+ if (!string.IsNullOrEmpty(endpoint.BaseUri))
+ {
+ return endpoint.BaseUri;
+ }
+ string authority = FormatHost(endpoint.Host);
+ int defaultPort = GetDefaultPort(endpoint.Scheme);
+ if (endpoint.Port >= 0 && endpoint.Port != defaultPort)
+ {
+ authority += ":" + endpoint.Port.ToString(CultureInfo.InvariantCulture);
+ }
+ return endpoint.Scheme + "://" + authority;
+ }
+
+ private static string FormatHost(string? host)
+ {
+ if (string.IsNullOrEmpty(host) ||
+ host[0] == '[' ||
+ !host.Contains(':', StringComparison.Ordinal))
+ {
+ return host ?? string.Empty;
+ }
+ return "[" + host + "]";
+ }
+
+ private static int GetDefaultPort(string scheme)
+ {
+ if (string.Equals(scheme, "opc.tcp", StringComparison.OrdinalIgnoreCase))
+ {
+ return 4840;
+ }
+ if (string.Equals(scheme, "opc.https", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(scheme, "opc.wss", StringComparison.OrdinalIgnoreCase))
+ {
+ return 443;
+ }
+ return -1;
+ }
+
+ private readonly OpcUaWotBindingOptions m_options;
+ }
+}
diff --git a/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs
new file mode 100644
index 0000000000..8787f005fa
--- /dev/null
+++ b/src/Opc.Ua.WotCon.Bindings/OpcUa/OpcUaWotBindingOptions.cs
@@ -0,0 +1,74 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+using Opc.Ua.Client;
+
+namespace Opc.Ua.WotCon.Bindings.OpcUa
+{
+ ///
+ /// Options for the OPC UA WoT binding executor. The session factory connects a
+ /// client session to the target endpoint and is injectable so callers control
+ /// the application configuration, security and identity.
+ ///
+ public sealed class OpcUaWotBindingOptions
+ {
+ ///
+ /// Gets or sets the factory that connects an to the
+ /// supplied opc.tcp endpoint. It is required for execution.
+ ///
+ public Func>? SessionFactory { get; set; }
+
+ ///
+ /// Gets or sets whether the executor disposes the session when the channel
+ /// is disposed. Set to false when a shared, caller-owned session is
+ /// returned by the factory.
+ ///
+ public bool DisposeSession { get; set; } = true;
+
+ ///
+ /// Gets or sets the sampling / publishing interval used for observe and
+ /// event subscriptions. Observe and event notifications are delivered by
+ /// a native OPC UA /
+ /// pair (Part 4 §5.13 / §5.12); this bounds how fast the server samples
+ /// and publishes, not a client-side poll.
+ ///
+ public TimeSpan ObserveInterval { get; set; } = TimeSpan.FromSeconds(1);
+
+ ///
+ /// Gets or sets the bounded monitored-item queue size requested for
+ /// event subscriptions, so a burst of events cannot grow the server-side
+ /// queue without bound. Property observe monitored items always request
+ /// a queue size of 1 (only the latest value is relevant).
+ ///
+ public uint EventQueueSize { get; set; } = 10;
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs
new file mode 100644
index 0000000000..8fb5f49a38
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ExecutorUnitTests.cs
@@ -0,0 +1,262 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Collections.Immutable;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+using Opc.Ua.WotCon.Bindings.Modbus;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Unit tests for executor identity, dispatch and HTTP error mapping.
+ ///
+ [TestFixture]
+ public sealed class ExecutorUnitTests
+ {
+ private static WotCompiledForm Compiled(string bindingId, string scheme)
+ {
+ return new WotCompiledForm(
+ new WotBindingIdentity(bindingId, "1.0", "urn:x"),
+ WotAffordanceKind.Property, "p", "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.ReadProperty, "readproperty",
+ new WotEndpointDescriptor(scheme, "h", 1, scheme + "://h"),
+ new WotAddressingDescriptor("t"),
+ new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"),
+ new WotPayloadDescriptor("application/json", "json"),
+ [], isExecutable: true);
+ }
+
+ private static WotEndpointPolicy AllowLoopbackPolicy()
+ {
+ return new WotEndpointPolicy { AllowLoopback = true };
+ }
+
+ [Test]
+ public void CanExecuteMatchesOwnBindingOnly()
+ {
+ var http = new HttpWotBindingExecutor();
+ var modbus = new ModbusWotBindingExecutor();
+
+ Assert.That(http.CanExecute(Compiled("w3c.http", "https")), Is.True);
+ Assert.That(http.CanExecute(Compiled("w3c.modbus", "modbus+tcp")), Is.False);
+ Assert.That(modbus.CanExecute(Compiled("w3c.modbus", "modbus+tcp")), Is.True);
+ Assert.That(modbus.CanExecute(Compiled("w3c.http", "https")), Is.False);
+ }
+
+ [Test]
+ public void ExecutorsIdentifyTheirPlannerBinding()
+ {
+ Assert.That(new HttpWotBindingExecutor().Identity.Id, Is.EqualTo(new HttpBindingPlanner().Identity.Id));
+ Assert.That(new ModbusWotBindingExecutor().Identity.Id, Is.EqualTo(new ModbusBindingPlanner().Identity.Id));
+ }
+
+ [Test]
+ public async Task HttpErrorStatusMapping()
+ {
+ (int Http, StatusCode Expected)[] cases =
+ [
+ (400, StatusCodes.BadInvalidArgument),
+ (401, StatusCodes.BadUserAccessDenied),
+ (404, StatusCodes.BadNodeIdUnknown),
+ (500, StatusCodes.BadInternalError)
+ ];
+
+ foreach ((int http, StatusCode expected) in cases)
+ {
+ using var server = new TestHttpServer((method, path, body) =>
+ new TestHttpResponse(http, "application/json", Encoding.UTF8.GetBytes("\"x\"")));
+
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor()],
+ endpointPolicy: AllowLoopbackPolicy());
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ server.BaseUrl +
+ "/p\"}]}}}";
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Status, Is.EqualTo(expected), $"HTTP {http} mapping.");
+ }
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelRejectsInvalidContentTypeAtSink()
+ {
+ int requests = 0;
+ using var server = new TestHttpServer(request =>
+ {
+ requests++;
+ return TestHttpResponse.Json(200, "\"ok\"");
+ });
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor()],
+ endpointPolicy: AllowLoopbackPolicy());
+ var form = new WotCompiledForm(
+ new HttpBindingPlanner().Identity,
+ WotAffordanceKind.Property,
+ "p",
+ "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.WriteProperty,
+ "writeproperty",
+ new WotEndpointDescriptor("http", "127.0.0.1", server.Port, server.BaseUrl),
+ new WotAddressingDescriptor(server.BaseUrl + "/p"),
+ new WotOperationDescriptor(WoTBindingCapabilityEnum.WriteProperty, "writeproperty", "PUT"),
+ new WotPayloadDescriptor("application/json\r\nX-Injected: pwned", "json"),
+ [],
+ isExecutable: true);
+
+ await using IWotBindingChannel channel = await registry.OpenChannelAsync(form).ConfigureAwait(false);
+ WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(42L))).ConfigureAwait(false);
+
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ Assert.That(requests, Is.Zero);
+ }
+
+ [Test]
+ public async Task HttpChannelRejectsInvalidDefaultHeader()
+ {
+ using var server = new TestHttpServer(_ => TestHttpResponse.Json(200, "1"));
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor(new HttpWotBindingOptions
+ {
+ DefaultHeaders = new System.Collections.Generic.Dictionary
+ {
+ ["X-Test"] = "ok\r\nX-Injected: pwned"
+ }
+ })],
+ endpointPolicy: AllowLoopbackPolicy());
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ server.BaseUrl +
+ "/p\"}]}}}";
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ await using IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
+ }
+
+ [Test]
+ public async Task HttpChannelRejectsInvalidCredentialHeader()
+ {
+ using var server = new TestHttpServer(_ => TestHttpResponse.Json(200, "1"));
+ var credential = new WotCredential(
+ WotSecurityScheme.Bearer,
+ ImmutableDictionary.Empty.Add("Authorization", "Bearer token\r\nMetadata: true"));
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor()],
+ credentials: new StaticCredentialProvider(credential),
+ endpointPolicy: AllowLoopbackPolicy());
+ var security = ImmutableArray.Create(new WotCredentialReference(
+ "bearer_sc",
+ WotSecurityScheme.Bearer,
+ HttpBindingPlanner.BindingUri,
+ server.BaseUrl));
+ var form = new WotCompiledForm(
+ new HttpBindingPlanner().Identity,
+ WotAffordanceKind.Property,
+ "p",
+ "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.ReadProperty,
+ "readproperty",
+ new WotEndpointDescriptor("http", "127.0.0.1", server.Port, server.BaseUrl),
+ new WotAddressingDescriptor(server.BaseUrl + "/p"),
+ new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "GET"),
+ new WotPayloadDescriptor("application/json", "json"),
+ security,
+ isExecutable: true);
+
+ await using IWotBindingChannel channel = await registry.OpenChannelAsync(form).ConfigureAwait(false);
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
+ }
+
+ [Test]
+ public void ModbusWriteMultipleRegistersRejectsNullValues()
+ {
+ using var client = new ModbusTcpClient("127.0.0.1", 502, System.TimeSpan.FromSeconds(1));
+
+ Assert.ThrowsAsync(
+ async () => await client.WriteMultipleRegistersAsync(
+ 1, 0, null!, CancellationToken.None).ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusWriteMultipleRegistersRejectsOutOfRangeValues()
+ {
+ using var client = new ModbusTcpClient("127.0.0.1", 502, System.TimeSpan.FromSeconds(1));
+ ushort[] values = new ushort[ModbusProtocolLimits.MaxWriteRegisters + 1];
+
+ Assert.ThrowsAsync(
+ async () => await client.WriteMultipleRegistersAsync(
+ 1, 0, values, CancellationToken.None).ConfigureAwait(false));
+ }
+
+ private sealed class StaticCredentialProvider : IWotCredentialProvider
+ {
+ public StaticCredentialProvider(WotCredential credential)
+ {
+ m_credential = credential;
+ }
+
+ public ValueTask ResolveAsync(
+ WotCredentialReference reference, CancellationToken cancellationToken = default)
+ {
+ return new ValueTask(m_credential);
+ }
+
+ private readonly WotCredential m_credential;
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs
new file mode 100644
index 0000000000..ef08490c15
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpCredentialResolutionTests.cs
@@ -0,0 +1,178 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Tests that HTTP credential resolution is race-free: concurrent requests on
+ /// a single channel resolve the credential exactly once and never send a
+ /// request before the credential is applied, and a failed resolution is
+ /// retried on the next request.
+ ///
+ [TestFixture]
+ public sealed class HttpCredentialResolutionTests
+ {
+ private const string SecuredTd =
+ "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"securityDefinitions\":{\"apikey_sc\":{\"scheme\":\"apikey\",\"in\":\"query\",\"name\":\"token\"}}," +
+ "\"security\":\"apikey_sc\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"{BASE}/p\"}]}}}";
+
+ private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string baseUrl)
+ {
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription,
+ Encoding.UTF8.GetBytes(SecuredTd.Replace("{BASE}", baseUrl, StringComparison.Ordinal))));
+ return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ }
+
+ [Test]
+ public async Task ConcurrentRequestsNeverSendUnauthenticatedAndResolveOnce()
+ {
+ var authenticated = new ConcurrentQueue();
+ using var server = new TestHttpServer((method, path, body) =>
+ {
+ // The resolved API key is carried as a query parameter, so an
+ // authenticated request has "token=secret" in its target.
+ authenticated.Enqueue(path.Contains("token=secret", StringComparison.Ordinal));
+ return TestHttpResponse.Json(200, "1");
+ });
+
+ var credentials = new SlowQueryCredentialProvider(TimeSpan.FromMilliseconds(150));
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [
+ new HttpWotBindingExecutor()
+ ],
+ credentials: credentials,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl))
+ .ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ Task[] reads = [.. Enumerable.Range(0, 12).Select(_ => channel.ReadAsync().AsTask())];
+ WotReadResult[] results = await Task.WhenAll(reads).ConfigureAwait(false);
+ Assert.That(results.All(r => r.Success), Is.True, "Every concurrent read must succeed.");
+ }
+
+ Assert.That(authenticated, Has.Count.EqualTo(12));
+ Assert.That(authenticated.All(a => a), Is.True,
+ "No request may be sent before the credential is resolved and applied.");
+ Assert.That(credentials.ResolveCount, Is.EqualTo(1),
+ "The credential must be resolved exactly once and shared across concurrent requests.");
+ }
+
+ [Test]
+ public async Task CredentialResolutionFailureIsRetriedOnNextRequest()
+ {
+ using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(200, "1"));
+
+ var credentials = new FailOnceCredentialProvider();
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [
+ new HttpWotBindingExecutor()
+ ],
+ credentials: credentials,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(ReadForm(registry, server.BaseUrl))
+ .ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ // The first resolution faults and must surface, not be cached.
+ Assert.ThrowsAsync(
+ async () => await channel.ReadAsync().ConfigureAwait(false));
+
+ // The next request retries resolution and succeeds.
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ Assert.That(credentials.ResolveCount, Is.EqualTo(2),
+ "A failed resolution must not be cached; the next request retries it.");
+ }
+
+ private sealed class SlowQueryCredentialProvider : IWotCredentialProvider
+ {
+ public SlowQueryCredentialProvider(TimeSpan delay)
+ {
+ m_delay = delay;
+ }
+
+ public int ResolveCount => Volatile.Read(ref m_resolveCount);
+
+ public async ValueTask ResolveAsync(
+ WotCredentialReference reference, CancellationToken cancellationToken = default)
+ {
+ Interlocked.Increment(ref m_resolveCount);
+ await Task.Delay(m_delay, cancellationToken).ConfigureAwait(false);
+ return new WotCredential(
+ WotSecurityScheme.ApiKey,
+ queryParameters: ImmutableDictionary.Empty.Add("token", "secret"));
+ }
+
+ private readonly TimeSpan m_delay;
+ private int m_resolveCount;
+ }
+
+ private sealed class FailOnceCredentialProvider : IWotCredentialProvider
+ {
+ public int ResolveCount => Volatile.Read(ref m_resolveCount);
+
+ public ValueTask ResolveAsync(
+ WotCredentialReference reference, CancellationToken cancellationToken = default)
+ {
+ if (Interlocked.Increment(ref m_resolveCount) == 1)
+ {
+ throw new InvalidOperationException("Transient credential resolution failure.");
+ }
+ return new ValueTask(new WotCredential(
+ WotSecurityScheme.ApiKey,
+ queryParameters: ImmutableDictionary.Empty.Add("token", "secret")));
+ }
+
+ private int m_resolveCount;
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs
new file mode 100644
index 0000000000..79f5baa803
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpRedirectSecurityTests.cs
@@ -0,0 +1,877 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// End-to-end tests for the HTTP executor's redirect-safe credential policy:
+ /// the executor-owned client disables automatic redirects and ambient cookies,
+ /// then applies a bounded, origin-aware redirect policy that drops custom header
+ /// / query credentials across origins, refuses loops and unsafe schemes, and
+ /// honours a redirect limit. Default headers follow the same sensitive-data
+ /// policy and are snapshotted per channel. Every caller-supplied client fails
+ /// closed unless the caller confirms safe redirect handling.
+ ///
+ [TestFixture]
+ public sealed class HttpRedirectSecurityTests
+ {
+ private const string SecuredTdTemplate =
+ "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"securityDefinitions\":{\"apikey_sc\":{\"scheme\":\"apikey\",\"in\":\"query\",\"name\":\"token\"}}," +
+ "\"security\":\"apikey_sc\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"{HREF}\"}]}}}";
+
+ private static WotProtocolBinderRegistry OwnedRegistry(
+ IWotCredentialProvider? credentials = null, HttpWotBindingOptions? options = null)
+ {
+ return new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions())],
+ credentials: credentials,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotCompiledForm ReadForm(WotProtocolBinderRegistry registry, string href)
+ {
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription,
+ Encoding.UTF8.GetBytes(SecuredTdTemplate.Replace("{HREF}", href, StringComparison.Ordinal))));
+ return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ }
+
+ [Test]
+ public async Task CrossOriginRedirectDropsHeaderAndQueryCredentials()
+ {
+ var origin = new Recorder();
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "7");
+ });
+ using var originServer = new TestHttpServer(request =>
+ {
+ origin.Record(request);
+ return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p");
+ });
+
+ WotProtocolBinderRegistry registry = OwnedRegistry(new HeaderQueryCredentialProvider());
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadForm(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, "The read must follow the redirect and succeed.");
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(7L));
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(origin.SawQueryToken, Is.True, "The origin request must carry the query credential.");
+ Assert.That(origin.SawHeaderToken, Is.True, "The origin request must carry the header credential.");
+ Assert.That(target.SawQueryToken, Is.False,
+ "A cross-origin redirect must not forward the query credential.");
+ Assert.That(target.SawHeaderToken, Is.False,
+ "A cross-origin redirect must not forward the header credential.");
+ });
+ }
+
+ [Test]
+ public async Task CrossOriginRedirectDropsDefaultHeaders()
+ {
+ var origin = new Recorder();
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "8");
+ });
+ using var originServer = new TestHttpServer(request =>
+ {
+ origin.Record(request);
+ return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p");
+ });
+
+ WotProtocolBinderRegistry registry = OwnedRegistry(
+ options: DefaultHeaderOptions());
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, "The read must follow the redirect and succeed.");
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(8L));
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(origin.SawDefaultHeaderToken, Is.True,
+ "The origin request must carry the configured default header.");
+ Assert.That(target.SawDefaultHeaderToken, Is.False,
+ "A cross-origin redirect must not forward the configured default header.");
+ });
+ }
+
+ [Test]
+ public async Task CrossOriginRedirectDoesNotReplaySetCookie()
+ {
+ var origin = new Recorder();
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "17");
+ });
+ using var originServer = new TestHttpServer(request =>
+ {
+ origin.Record(request);
+ return RedirectWithCookie(targetServer.BaseUrl + "/p");
+ });
+
+ WotProtocolBinderRegistry registry = OwnedRegistry();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(17L));
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(origin.SawHeaderContaining("Cookie", "session=secret"), Is.False);
+ Assert.That(target.SawHeaderContaining("Cookie", "session=secret"), Is.False,
+ "An ambient cookie from one origin must not cross to another port.");
+ });
+ }
+
+ [Test]
+ public async Task SameOriginRedirectKeepsCredentials()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ if (request.Path.StartsWith("/a", StringComparison.Ordinal))
+ {
+ return TestHttpResponse.Redirect("/b");
+ }
+ return TestHttpResponse.Json(200, "9");
+ });
+
+ WotProtocolBinderRegistry registry = OwnedRegistry(new HeaderQueryCredentialProvider());
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadForm(registry, server.BaseUrl + "/a")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(9L));
+ }
+
+ Assert.That(recorder.PathsSeen.Any(p => p.StartsWith("/b", StringComparison.Ordinal) &&
+ p.Contains("token=secret", StringComparison.Ordinal)), Is.True,
+ "A same-origin redirect must keep the query credential on the follow-up request.");
+ }
+
+ [Test]
+ public async Task SameOriginRedirectKeepsDefaultHeaders()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ if (request.Path.StartsWith("/a", StringComparison.Ordinal))
+ {
+ return TestHttpResponse.Redirect("/b");
+ }
+ return TestHttpResponse.Json(200, "10");
+ });
+
+ WotProtocolBinderRegistry registry = OwnedRegistry(
+ options: DefaultHeaderOptions());
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(10L));
+ }
+
+ Assert.That(recorder.PathsWithDefaultHeader.Any(
+ p => p.StartsWith("/b", StringComparison.Ordinal)), Is.True,
+ "A same-origin redirect must keep the configured default header.");
+ }
+
+ [Test]
+ public async Task SameOriginRedirectDoesNotReplaySetCookie()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ return request.Path.StartsWith("/a", StringComparison.Ordinal)
+ ? RedirectWithCookie("/b")
+ : TestHttpResponse.Json(200, "18");
+ });
+
+ WotProtocolBinderRegistry registry = OwnedRegistry();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(18L));
+ }
+
+ Assert.That(recorder.SawHeaderContainingOnPath(
+ "/b", "Cookie", "session=secret"), Is.False,
+ "The executor-owned client must not maintain an ambient cookie jar.");
+ }
+
+ [Test]
+ public async Task SameOriginRedirectKeepsExplicitCookieHeader()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ return request.Path.StartsWith("/a", StringComparison.Ordinal)
+ ? TestHttpResponse.Redirect("/b")
+ : TestHttpResponse.Json(200, "19");
+ });
+ var options = new HttpWotBindingOptions
+ {
+ DefaultHeaders = Headers("Cookie", "explicit=secret")
+ };
+
+ WotProtocolBinderRegistry registry = OwnedRegistry(options: options);
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(19L));
+ }
+
+ Assert.That(recorder.SawHeaderContainingOnPath(
+ "/b", "Cookie", "explicit=secret"), Is.True,
+ "An explicit cookie header follows the normal same-origin header policy.");
+ }
+
+ [Test]
+ public async Task RedirectLoopIsRejected()
+ {
+ using var server = new TestHttpServer(request =>
+ request.Path.StartsWith("/a", StringComparison.Ordinal)
+ ? TestHttpResponse.Redirect("/b")
+ : TestHttpResponse.Redirect("/a"));
+
+ WotProtocolBinderRegistry registry = OwnedRegistry();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Error, Does.Contain("loop").IgnoreCase);
+ }
+ }
+
+ [Test]
+ public async Task RedirectToDisallowedSchemeIsRejected()
+ {
+ using var server = new TestHttpServer(_ => TestHttpResponse.Redirect("ftp://evil.example.com/x"));
+
+ WotProtocolBinderRegistry registry = OwnedRegistry();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
+ }
+ }
+
+ [Test]
+ [TestCase("http://169.254.169.254/latest/meta-data/")]
+ [TestCase("http://10.0.0.1/admin")]
+ [TestCase("http://192.168.1.1/admin")]
+ [TestCase("http://[fc00::1]/admin")]
+ public async Task RedirectToPolicyBlockedEndpointIsRejectedAsync(string blockedTarget)
+ {
+ // The endpoint policy is applied to the form's own target before the channel
+ // opens. A permitted origin that redirects to a link-local or private address
+ // would otherwise reach it, so every hop must be re-validated.
+ using var server = new TestHttpServer(_ => TestHttpResponse.Redirect(blockedTarget));
+
+ WotProtocolBinderRegistry registry = OwnedRegistry();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadSecurityChecksFailed));
+ }
+ }
+
+ [Test]
+ public async Task RedirectToPrivateEndpointIsAllowedWhenPolicyOptsInAsync()
+ {
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "7");
+ });
+ using var originServer = new TestHttpServer(
+ _ => TestHttpResponse.Redirect(targetServer.BaseUrl + "/p"));
+
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor(new HttpWotBindingOptions())],
+ endpointPolicy: new WotEndpointPolicy
+ {
+ AllowLoopback = true,
+ AllowPrivateAddresses = true
+ });
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, "An operator opt-in must still permit the redirect.");
+ }
+ }
+
+ [Test]
+ public async Task RedirectLimitIsEnforced()
+ {
+ int counter = 0;
+ using var server = new TestHttpServer(_ =>
+ TestHttpResponse.Redirect("/r" + Interlocked.Increment(ref counter)));
+
+ WotProtocolBinderRegistry registry = OwnedRegistry(
+ options: new HttpWotBindingOptions { MaxAutomaticRedirects = 2 });
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/start")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Error, Does.Contain("redirect limit").IgnoreCase);
+ }
+ }
+
+ [Test]
+ public async Task OwnedClientFollowsTemporaryRedirectToSuccess()
+ {
+ using var server = new TestHttpServer(request =>
+ request.Path.StartsWith("/a", StringComparison.Ordinal)
+ ? TestHttpResponse.Redirect("/final", status: 307)
+ : TestHttpResponse.Json(200, "5"));
+
+ WotProtocolBinderRegistry registry = OwnedRegistry();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/a")).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(5L));
+ }
+ }
+
+ [Test]
+ public async Task DefaultHeadersAddedAfterActivationAreIgnored()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ return TestHttpResponse.Json(200, "12");
+ });
+ var options = new HttpWotBindingOptions();
+ WotProtocolBinderRegistry registry = OwnedRegistry(options: options);
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false);
+
+ options.DefaultHeaders = Headers("X-Added-Secret", "added");
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ Assert.That(recorder.SawHeader("X-Added-Secret", "added"), Is.False,
+ "Headers configured after activation must not enter the channel snapshot.");
+ }
+
+ [Test]
+ public async Task DefaultHeadersReplacedAfterActivationUseSnapshot()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ return TestHttpResponse.Json(200, "13");
+ });
+ var options = new HttpWotBindingOptions
+ {
+ DefaultHeaders = Headers("X-Snapshot-Secret", "original")
+ };
+ WotProtocolBinderRegistry registry = OwnedRegistry(options: options);
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false);
+
+ options.DefaultHeaders = Headers("X-Replacement-Secret", "replacement");
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(recorder.SawHeader("X-Snapshot-Secret", "original"), Is.True);
+ Assert.That(recorder.SawHeader("X-Replacement-Secret", "replacement"), Is.False);
+ });
+ }
+
+ [Test]
+ public async Task DefaultHeaderCollectionMutationAfterActivationUsesSnapshot()
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ return TestHttpResponse.Json(200, "14");
+ });
+ var mutableHeaders = new Dictionary
+ {
+ ["X-Mutable-Secret"] = "original"
+ };
+ var options = new HttpWotBindingOptions
+ {
+ DefaultHeaders = mutableHeaders
+ };
+ WotProtocolBinderRegistry registry = OwnedRegistry(options: options);
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, server.BaseUrl + "/p")).ConfigureAwait(false);
+
+ mutableHeaders["X-Mutable-Secret"] = "mutated";
+ mutableHeaders["X-Late-Secret"] = "late";
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(recorder.SawHeader("X-Mutable-Secret", "original"), Is.True);
+ Assert.That(recorder.SawHeader("X-Mutable-Secret", "mutated"), Is.False);
+ Assert.That(recorder.SawHeader("X-Late-Secret", "late"), Is.False);
+ });
+ }
+
+ [Test]
+ public void CallerSuppliedClientWithCredentialFormFailsClosed()
+ {
+ using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(200, "1"));
+ using var client = new HttpClient();
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [
+ new HttpWotBindingExecutor(new HttpWotBindingOptions { ClientFactory = () => client })
+ ],
+ credentials: new HeaderQueryCredentialProvider(),
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ WotCompiledForm read = ReadForm(registry, server.BaseUrl + "/p");
+
+ Assert.ThrowsAsync(
+ async () => await registry.OpenChannelAsync(read).ConfigureAwait(false));
+ }
+
+ [Test]
+ public void CallerSuppliedAutoRedirectClientWithDefaultHeadersFailsClosed()
+ {
+ var origin = new Recorder();
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "1");
+ });
+ using var originServer = new TestHttpServer(request =>
+ {
+ origin.Record(request);
+ return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p");
+ });
+ using var client = new HttpClient(new HttpClientHandler
+ {
+ AllowAutoRedirect = true,
+ CheckCertificateRevocationList = true
+ });
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [
+ new HttpWotBindingExecutor(new HttpWotBindingOptions
+ {
+ ClientFactory = () => client,
+ DefaultHeaders = DefaultHeaderOptions().DefaultHeaders
+ })
+ ],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ WotCompiledForm read = ReadFormNoSecurity(registry, originServer.BaseUrl + "/p");
+
+ InvalidOperationException? exception = Assert.ThrowsAsync(
+ async () => await registry.OpenChannelAsync(read).ConfigureAwait(false));
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception?.Message,
+ Does.Contain(nameof(HttpWotBindingOptions.CallerClientHandlesRedirectSafety)));
+ Assert.That(origin.PathsSeen, Is.Empty,
+ "Rejected caller-owned clients must not send the origin request.");
+ Assert.That(target.PathsSeen, Is.Empty,
+ "Rejected caller-owned clients must not reach the redirect target.");
+ });
+ }
+
+ [TestCase(false)]
+ [TestCase(true)]
+ public void CallerSuppliedClientWithoutHeadersFailsClosed(bool configureEmptyHeaders)
+ {
+ var recorder = new Recorder();
+ using var server = new TestHttpServer(request =>
+ {
+ recorder.Record(request);
+ return TestHttpResponse.Json(200, "11");
+ });
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = true,
+ UseCookies = true,
+ CheckCertificateRevocationList = true
+ };
+ using var client = new HttpClient(handler);
+ int factoryCalls = 0;
+ var options = new HttpWotBindingOptions
+ {
+ ClientFactory = () =>
+ {
+ Interlocked.Increment(ref factoryCalls);
+ return client;
+ }
+ };
+ if (configureEmptyHeaders)
+ {
+ options.DefaultHeaders = ImmutableDictionary.Empty;
+ }
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor(options)],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ WotCompiledForm read = ReadFormNoSecurity(registry, server.BaseUrl + "/p");
+
+ InvalidOperationException? exception = Assert.ThrowsAsync(
+ async () => await registry.OpenChannelAsync(read).ConfigureAwait(false));
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception?.Message,
+ Does.Contain(nameof(HttpWotBindingOptions.CallerClientHandlesRedirectSafety)));
+ Assert.That(factoryCalls, Is.Zero);
+ Assert.That(recorder.PathsSeen, Is.Empty);
+ Assert.That(handler.UseCookies, Is.True,
+ "Opaque caller-owned cookie behavior requires explicit safety confirmation.");
+ });
+ }
+
+ [Test]
+ public async Task CallerDefaultRequestHeadersCannotLeakWithoutSafetyConfirmation()
+ {
+ var origin = new Recorder();
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "15");
+ });
+ using var originServer = new TestHttpServer(request =>
+ {
+ origin.Record(request);
+ return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p");
+ });
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = true,
+ CheckCertificateRevocationList = true
+ };
+ using var client = new HttpClient(handler);
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Secret", "client-secret");
+ int factoryCalls = 0;
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [
+ new HttpWotBindingExecutor(new HttpWotBindingOptions
+ {
+ ClientFactory = () =>
+ {
+ Interlocked.Increment(ref factoryCalls);
+ return client;
+ }
+ })
+ ],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ WotCompiledForm read = ReadFormNoSecurity(registry, originServer.BaseUrl + "/p");
+
+ InvalidOperationException? exception = Assert.ThrowsAsync(
+ async () => await registry.OpenChannelAsync(read).ConfigureAwait(false));
+ Assert.Multiple(() =>
+ {
+ Assert.That(exception?.Message,
+ Does.Contain(nameof(HttpWotBindingOptions.CallerClientHandlesRedirectSafety)));
+ Assert.That(factoryCalls, Is.Zero);
+ Assert.That(origin.PathsSeen, Is.Empty);
+ Assert.That(target.PathsSeen, Is.Empty);
+ Assert.That(handler.AllowAutoRedirect, Is.True);
+ Assert.That(handler.UseCookies, Is.True);
+ Assert.That(client.DefaultRequestHeaders.GetValues("X-Client-Secret").Single(),
+ Is.EqualTo("client-secret"));
+ });
+
+ using HttpResponseMessage response = await client
+ .GetAsync(new Uri(targetServer.BaseUrl + "/probe"))
+ .ConfigureAwait(false);
+ Assert.That(response.IsSuccessStatusCode, Is.True,
+ "Rejecting activation must not dispose the caller-owned client.");
+ }
+
+ [Test]
+ public async Task ConfirmedSafeCallerClientIsNotMutatedOrDisposed()
+ {
+ var origin = new Recorder();
+ var target = new Recorder();
+ using var targetServer = new TestHttpServer(request =>
+ {
+ target.Record(request);
+ return TestHttpResponse.Json(200, "16");
+ });
+ using var originServer = new TestHttpServer(request =>
+ {
+ origin.Record(request);
+ return TestHttpResponse.Redirect(targetServer.BaseUrl + "/p");
+ });
+ var handler = new HttpClientHandler
+ {
+ AllowAutoRedirect = false,
+ CheckCertificateRevocationList = true
+ };
+ using var client = new HttpClient(handler);
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-Client-Secret", "existing");
+ var options = new HttpWotBindingOptions
+ {
+ ClientFactory = () => client,
+ CallerClientHandlesRedirectSafety = true,
+ DefaultHeaders = Headers("X-Option-Snapshot", "original")
+ };
+ var registry = new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor(options)],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ IWotBindingChannel channel = await registry.OpenChannelAsync(
+ ReadFormNoSecurity(registry, originServer.BaseUrl + "/p")).ConfigureAwait(false);
+
+ options.DefaultHeaders = Headers("X-Option-Replacement", "replacement");
+ client.DefaultRequestHeaders.TryAddWithoutValidation("X-Late-Client-Secret", "late");
+ WotReadResult result;
+ await using (channel.ConfigureAwait(false))
+ {
+ result = await channel.ReadAsync().ConfigureAwait(false);
+ }
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(result.Success, Is.False,
+ "A confirmed-safe client with redirects disabled must not follow the redirect.");
+ Assert.That(origin.SawHeader("X-Option-Snapshot", "original"), Is.True);
+ Assert.That(origin.SawHeader("X-Option-Replacement", "replacement"), Is.False);
+ Assert.That(origin.SawHeader("X-Client-Secret", "existing"), Is.True);
+ Assert.That(origin.SawHeader("X-Late-Client-Secret", "late"), Is.True);
+ Assert.That(target.PathsSeen, Is.Empty);
+ Assert.That(handler.AllowAutoRedirect, Is.False);
+ Assert.That(handler.UseCookies, Is.True,
+ "The executor must not mutate caller-owned cookie behavior.");
+ Assert.That(client.DefaultRequestHeaders.Contains("X-Option-Snapshot"), Is.False);
+ Assert.That(client.DefaultRequestHeaders.GetValues("X-Client-Secret").Single(),
+ Is.EqualTo("existing"));
+ Assert.That(client.DefaultRequestHeaders.GetValues("X-Late-Client-Secret").Single(),
+ Is.EqualTo("late"));
+ });
+
+ using HttpResponseMessage response = await client
+ .GetAsync(new Uri(targetServer.BaseUrl + "/probe"))
+ .ConfigureAwait(false);
+ Assert.That(response.IsSuccessStatusCode, Is.True,
+ "Disposing the channel must not dispose the caller-owned client.");
+ }
+
+ private static WotCompiledForm ReadFormNoSecurity(WotProtocolBinderRegistry registry, string href)
+ {
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ href +
+ "\"}]}}}";
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ }
+
+ private static HttpWotBindingOptions DefaultHeaderOptions()
+ {
+ return new HttpWotBindingOptions
+ {
+ DefaultHeaders = Headers("X-Default-Secret", "secret")
+ };
+ }
+
+ private static ImmutableDictionary Headers(string name, string value)
+ {
+ return ImmutableDictionary.Empty.Add(name, value);
+ }
+
+ private static TestHttpResponse RedirectWithCookie(string location)
+ {
+ return new TestHttpResponse(
+ 302,
+ "text/plain",
+ [],
+ ImmutableDictionary.Empty
+ .Add("Location", location)
+ .Add("Set-Cookie", "session=secret; Path=/"));
+ }
+
+ private sealed class Recorder
+ {
+ private readonly System.Collections.Concurrent.ConcurrentQueue m_paths = new();
+ private readonly System.Collections.Concurrent.ConcurrentQueue m_pathsWithDefaultHeader = new();
+ private readonly System.Collections.Concurrent.ConcurrentQueue m_requests = new();
+ private int m_sawDefaultHeaderToken;
+ private int m_sawQueryToken;
+ private int m_sawHeaderToken;
+
+ public bool SawDefaultHeaderToken => Volatile.Read(ref m_sawDefaultHeaderToken) != 0;
+
+ public bool SawQueryToken => Volatile.Read(ref m_sawQueryToken) != 0;
+
+ public bool SawHeaderToken => Volatile.Read(ref m_sawHeaderToken) != 0;
+
+ public System.Collections.Generic.IReadOnlyCollection PathsSeen => [.. m_paths];
+
+ public System.Collections.Generic.IReadOnlyCollection PathsWithDefaultHeader =>
+ [.. m_pathsWithDefaultHeader];
+
+ public bool SawHeader(string name, string value)
+ {
+ return m_requests.Any(request =>
+ request.Headers.TryGetValue(name, out string? actual) &&
+ string.Equals(actual, value, StringComparison.Ordinal));
+ }
+
+ public bool SawHeaderContaining(string name, string value)
+ {
+ return m_requests.Any(request =>
+ request.Headers.TryGetValue(name, out string? actual) &&
+ actual.Contains(value, StringComparison.Ordinal));
+ }
+
+ public bool SawHeaderContainingOnPath(
+ string path, string name, string value)
+ {
+ return m_requests.Any(request =>
+ request.Path.StartsWith(path, StringComparison.Ordinal) &&
+ request.Headers.TryGetValue(name, out string? actual) &&
+ actual.Contains(value, StringComparison.Ordinal));
+ }
+
+ public void Record(TestHttpRequest request)
+ {
+ m_requests.Enqueue(request);
+ m_paths.Enqueue(request.Path);
+ if (request.Headers.TryGetValue("X-Default-Secret", out string? defaultValue) &&
+ string.Equals(defaultValue, "secret", StringComparison.Ordinal))
+ {
+ m_pathsWithDefaultHeader.Enqueue(request.Path);
+ Interlocked.Exchange(ref m_sawDefaultHeaderToken, 1);
+ }
+ if (request.Path.Contains("token=secret", StringComparison.Ordinal))
+ {
+ Interlocked.Exchange(ref m_sawQueryToken, 1);
+ }
+ if (request.Headers.TryGetValue("X-Api-Key", out string? value) &&
+ string.Equals(value, "secret", StringComparison.Ordinal))
+ {
+ Interlocked.Exchange(ref m_sawHeaderToken, 1);
+ }
+ }
+ }
+
+ private sealed class HeaderQueryCredentialProvider : IWotCredentialProvider
+ {
+ public ValueTask ResolveAsync(
+ WotCredentialReference reference, CancellationToken cancellationToken = default)
+ {
+ return new ValueTask(new WotCredential(
+ WotSecurityScheme.ApiKey,
+ headers: ImmutableDictionary.Empty.Add("X-Api-Key", "secret"),
+ queryParameters: ImmutableDictionary.Empty.Add("token", "secret")));
+ }
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs
new file mode 100644
index 0000000000..6085f30e5c
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpStatusMapperTests.cs
@@ -0,0 +1,168 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Net;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Unit tests for the HTTP status code to OPC UA StatusCode mapper.
+ ///
+ [TestFixture]
+ public sealed class HttpStatusMapperTests
+ {
+ [Test]
+ public void OkMapsToGood()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.OK),
+ Is.EqualTo(StatusCodes.Good));
+ }
+
+ [Test]
+ public void CreatedMapsToGood()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.Created),
+ Is.EqualTo(StatusCodes.Good));
+ }
+
+ [Test]
+ public void NoContentMapsToGood()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.NoContent),
+ Is.EqualTo(StatusCodes.Good));
+ }
+
+ [Test]
+ public void PartialContentMapsToGood()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.PartialContent),
+ Is.EqualTo(StatusCodes.Good));
+ }
+
+ [Test]
+ public void BadRequestMapsToBadInvalidArgument()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.BadRequest),
+ Is.EqualTo(StatusCodes.BadInvalidArgument));
+ }
+
+ [Test]
+ public void UnauthorizedMapsToBadUserAccessDenied()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.Unauthorized),
+ Is.EqualTo(StatusCodes.BadUserAccessDenied));
+ }
+
+ [Test]
+ public void ForbiddenMapsToBadUserAccessDenied()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.Forbidden),
+ Is.EqualTo(StatusCodes.BadUserAccessDenied));
+ }
+
+ [Test]
+ public void NotFoundMapsToBadNodeIdUnknown()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.NotFound),
+ Is.EqualTo(StatusCodes.BadNodeIdUnknown));
+ }
+
+ [Test]
+ public void MethodNotAllowedMapsToBadNotSupported()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.MethodNotAllowed),
+ Is.EqualTo(StatusCodes.BadNotSupported));
+ }
+
+ [Test]
+ public void RequestTimeoutMapsToBadTimeout()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.RequestTimeout),
+ Is.EqualTo(StatusCodes.BadTimeout));
+ }
+
+ [Test]
+ public void ConflictMapsToBadInvalidState()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.Conflict),
+ Is.EqualTo(StatusCodes.BadInvalidState));
+ }
+
+ [Test]
+ public void NotImplementedMapsToBadNotImplemented()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.NotImplemented),
+ Is.EqualTo(StatusCodes.BadNotImplemented));
+ }
+
+ [Test]
+ public void ServiceUnavailableMapsToBadServerHalted()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.ServiceUnavailable),
+ Is.EqualTo(StatusCodes.BadServerHalted));
+ }
+
+ [Test]
+ public void GatewayTimeoutMapsToBadTimeout()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.GatewayTimeout),
+ Is.EqualTo(StatusCodes.BadTimeout));
+ }
+
+ [Test]
+ public void InternalServerErrorMapsToBadInternalError()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.InternalServerError),
+ Is.EqualTo(StatusCodes.BadInternalError));
+ }
+
+ [Test]
+ public void GenericFiveHundredXxMapsToBadInternalError()
+ {
+ Assert.That(HttpStatusMapper.Map((HttpStatusCode)599),
+ Is.EqualTo(StatusCodes.BadInternalError));
+ }
+
+ [Test]
+ public void GoneMapsToBadUnexpectedError()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.Gone),
+ Is.EqualTo(StatusCodes.BadUnexpectedError));
+ }
+
+ [Test]
+ public void RedirectMapsToBadUnexpectedError()
+ {
+ Assert.That(HttpStatusMapper.Map(HttpStatusCode.MovedPermanently),
+ Is.EqualTo(StatusCodes.BadUnexpectedError));
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs
new file mode 100644
index 0000000000..a3a5ca5b43
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotBindingChannelTests.cs
@@ -0,0 +1,362 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Additional channel-level tests for HttpWotBindingChannel:
+ /// non-2xx responses mapped to OPC UA status codes, body-size enforcement,
+ /// codec decode failure, write semantics, action invocation with empty
+ /// body, and polling subscription creation.
+ ///
+ [TestFixture]
+ public sealed class HttpWotBindingChannelTests
+ {
+ private static WotProtocolBinderRegistry Registry(
+ HttpWotBindingOptions? options = null,
+ WotBindingBounds? bounds = null)
+ {
+ return new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [new HttpWotBindingExecutor(options ?? new HttpWotBindingOptions
+ {
+ ClientFactory = () => new HttpClient(),
+ CallerClientHandlesRedirectSafety = true
+ })],
+ bounds: bounds,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td)
+ {
+ return registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ }
+
+ private static string PropertyTd(string baseUrl, string contentType = "application/json")
+ {
+ return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ baseUrl + "/prop\",\"contentType\":\"" + contentType + "\"}]}}}";
+ }
+
+ private static string ActionTd(string baseUrl, string contentType = "application/json")
+ {
+ return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"actions\":{\"act\":{\"forms\":[{\"href\":\"" +
+ baseUrl + "/action\",\"contentType\":\"" + contentType + "\"}]}}}";
+ }
+
+ [Test]
+ public async Task HttpChannelReadNon2xxStatusReturnsMappedStatusCode()
+ {
+ using var server = new TestHttpServer((_, _, _) =>
+ new TestHttpResponse(404, "text/plain", Encoding.UTF8.GetBytes("not found")));
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ // HTTP 404 maps to BadNotFound via HttpStatusMapper.
+ Assert.That(StatusCode.IsBad(result.Status), Is.True);
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelReadServerErrorStatusReturnsMappedStatusCode()
+ {
+ using var server = new TestHttpServer((_, _, _) =>
+ new TestHttpResponse(500, "text/plain", Encoding.UTF8.GetBytes("error")));
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(StatusCode.IsBad(result.Status), Is.True);
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelReadBodyTooLargeReturnsBadEncodingLimitsExceeded()
+ {
+ // Respond with a body larger than the configured limit.
+ byte[] bigBody = Encoding.UTF8.GetBytes("\"" + new string('x', 20) + "\"");
+ using var server = new TestHttpServer((_, _, _) =>
+ new TestHttpResponse(200, "application/json", bigBody));
+
+ // Limit MaxPayloadBytes to 10 so the 20-char body exceeds it.
+ var bounds = new WotBindingBounds { MaxPayloadBytes = 10 };
+ WotProtocolBinderRegistry registry = Registry(bounds: bounds);
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadEncodingLimitsExceeded));
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelReadBadDecodeReturnsBadDecodingError()
+ {
+ // Respond with malformed JSON so the JSON codec fails to decode.
+ byte[] badJson = Encoding.UTF8.GetBytes("{this is not valid json!}");
+ using var server = new TestHttpServer((_, _, _) =>
+ new TestHttpResponse(200, "application/json", badJson));
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadDecodingError));
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelWriteReturnsGoodOnSuccess()
+ {
+ using var server = new TestHttpServer((method, path, _) =>
+ {
+ if (method == "PUT" && path == "/prop")
+ {
+ return new TestHttpResponse(200, "application/json", Encoding.UTF8.GetBytes("OK"));
+ }
+ return new TestHttpResponse(405, "text/plain", []);
+ });
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant(42L))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelWriteNon2xxReturnsMappedStatusCode()
+ {
+ using var server = new TestHttpServer((_, _, _) =>
+ new TestHttpResponse(403, "text/plain", Encoding.UTF8.GetBytes("forbidden")));
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant(1L))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(StatusCode.IsBad(result.Status), Is.True);
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelInvokeWithEmptyResponseBodyReturnsGoodWithNoOutput()
+ {
+ using var server = new TestHttpServer((method, path, _) =>
+ {
+ if (method == "POST" && path == "/action")
+ {
+ // Return 200 with empty body.
+ return new TestHttpResponse(200, "application/json", []);
+ }
+ return new TestHttpResponse(404, "text/plain", []);
+ });
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, ActionTd(server.BaseUrl));
+ WotCompiledForm invoke = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Outputs, Is.Empty);
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelInvokeWithInputsAndJsonResponseDecodesOutput()
+ {
+ using var server = new TestHttpServer((method, path, _) =>
+ {
+ if (method == "POST" && path == "/action")
+ {
+ return new TestHttpResponse(200, "application/json", Encoding.UTF8.GetBytes("99"));
+ }
+ return new TestHttpResponse(404, "text/plain", []);
+ });
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, ActionTd(server.BaseUrl));
+ WotCompiledForm invoke = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync(
+ [new Variant(1L)]).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Outputs, Has.Count.EqualTo(1));
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelObserveAsyncCreatesPollingSubscription()
+ {
+ int pollCount = 0;
+ using var server = new TestHttpServer((_, _, _) =>
+ {
+ Interlocked.Increment(ref pollCount);
+ return TestHttpResponse.Json(200, pollCount.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ });
+
+ WotProtocolBinderRegistry registry = Registry(
+ options: new HttpWotBindingOptions
+ {
+ ClientFactory = () => new HttpClient(),
+ CallerClientHandlesRedirectSafety = true,
+ ObserveInterval = TimeSpan.FromMilliseconds(100)
+ });
+
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ var received = new ConcurrentQueue();
+ IWotSubscription sub = await channel.ObserveAsync(n => received.Enqueue(n))
+ .ConfigureAwait(false);
+ await using (sub.ConfigureAwait(false))
+ {
+ // Wait for at least one notification from the polling loop.
+ bool got = false;
+ for (int i = 0; i < 80 && !got; i++)
+ {
+ if (!received.IsEmpty)
+ {
+ got = true;
+ }
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ Assert.That(got, Is.True, "ObserveAsync should create a polling subscription that delivers data.");
+ }
+ }
+ }
+
+ [Test]
+ public async Task HttpChannelSubscribeEventAsyncDelegatesToObserve()
+ {
+ using var server = new TestHttpServer((_, _, _) =>
+ TestHttpResponse.Json(200, "123"));
+
+ WotProtocolBinderRegistry registry = Registry(
+ options: new HttpWotBindingOptions
+ {
+ ClientFactory = () => new HttpClient(),
+ CallerClientHandlesRedirectSafety = true,
+ ObserveInterval = TimeSpan.FromMilliseconds(100)
+ });
+
+ WotBindingPlan plan = Plan(registry, PropertyTd(server.BaseUrl));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ var received = new ConcurrentQueue();
+ IWotSubscription sub = await channel.SubscribeEventAsync(n => received.Enqueue(n))
+ .ConfigureAwait(false);
+ await using (sub.ConfigureAwait(false))
+ {
+ bool got = false;
+ for (int i = 0; i < 80 && !got; i++)
+ {
+ if (!received.IsEmpty)
+ {
+ got = true;
+ }
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ Assert.That(got, Is.True,
+ "SubscribeEventAsync should delegate to ObserveAsync and deliver notifications.");
+ }
+ }
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs
new file mode 100644
index 0000000000..fd093a5127
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/HttpWotExecutorTests.cs
@@ -0,0 +1,217 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net.Http;
+using System.Text;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// End-to-end tests for the HTTP executor against an in-process HTTP server.
+ ///
+ [TestFixture]
+ public sealed class HttpWotExecutorTests
+ {
+ private static WotProtocolBinderRegistry Registry(HttpWotBindingOptions? options = null)
+ {
+ return new WotProtocolBinderRegistry(
+ [new HttpBindingPlanner()],
+ [ new HttpWotBindingExecutor(options ??
+ new HttpWotBindingOptions()) ],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td)
+ {
+ return registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ }
+
+ [Test]
+ public async Task HttpReadWriteActionEndToEnd()
+ {
+ var store = new ConcurrentDictionary();
+ store["/prop"] = "10";
+ using var server = new TestHttpServer((method, path, body) =>
+ {
+ if (path == "/prop" && method == "GET")
+ {
+ return TestHttpResponse.Json(200, store.GetValueOrDefault("/prop", "0"));
+ }
+ if (path == "/prop" && method == "PUT")
+ {
+ store["/prop"] = Encoding.UTF8.GetString(body);
+ return new TestHttpResponse(204, "text/plain", []);
+ }
+ if (path == "/action" && method == "POST")
+ {
+ return TestHttpResponse.Json(200, "\"done\"");
+ }
+ return TestHttpResponse.Json(404, "\"missing\"");
+ });
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ server.BaseUrl +
+ "/prop\"}]}}," +
+ "\"actions\":{\"act\":{\"forms\":[{\"href\":\"" +
+ server.BaseUrl +
+ "/action\"}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+ WotCompiledForm invoke = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (readChannel.ConfigureAwait(false))
+ {
+ WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(10L));
+ }
+
+ IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (writeChannel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await writeChannel.WriteAsync(
+ new DataValue(new Variant(42L))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ IWotBindingChannel reread = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (reread.ConfigureAwait(false))
+ {
+ WotReadResult result = await reread.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42L));
+ }
+
+ IWotBindingChannel actionChannel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false);
+ await using (actionChannel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await actionChannel.InvokeAsync([]).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Outputs, Has.Count.EqualTo(1));
+ Assert.That(result.Outputs[0].WrappedValue.AsBoxedObject(), Is.EqualTo("done"));
+ }
+ }
+
+ [Test]
+ public async Task HttpObserveDeliversValueChanges()
+ {
+ var store = new ConcurrentDictionary();
+ store["/prop"] = "1";
+ using var server = new TestHttpServer((method, path, body) =>
+ TestHttpResponse.Json(200, store.GetValueOrDefault("/prop", "0")));
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"w\":{\"type\":\"number\",\"observable\":true,\"forms\":[{\"href\":\"" +
+ server.BaseUrl +
+ "/prop\",\"op\":[\"observeproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry(new HttpWotBindingOptions
+ {
+ ObserveInterval = TimeSpan.FromMilliseconds(100)
+ });
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm observe = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty);
+
+ var received = new ConcurrentQueue();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(observe).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ IWotSubscription subscription = await channel.ObserveAsync(n =>
+ {
+ if (n.Value.WrappedValue.AsBoxedObject() is long value)
+ {
+ received.Enqueue(value);
+ }
+ }).ConfigureAwait(false);
+ await using (subscription.ConfigureAwait(false))
+ {
+ store["/prop"] = "99";
+ Assert.That(
+ await WaitForAsync(received, 99).ConfigureAwait(false),
+ Is.True,
+ "The observe channel must deliver the change.");
+ }
+ }
+ }
+
+ [Test]
+ public async Task HttpNotFoundMapsToBadNodeIdUnknown()
+ {
+ using var server = new TestHttpServer((method, path, body) => TestHttpResponse.Json(404, "\"no\""));
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ server.BaseUrl +
+ "/x\"}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
+ }
+ }
+
+ private static async Task WaitForAsync(ConcurrentQueue queue, long expected)
+ {
+ for (int i = 0; i < 50; i++)
+ {
+ if (queue.Contains(expected))
+ {
+ return true;
+ }
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ return false;
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs
new file mode 100644
index 0000000000..9fac88cbcc
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterAdditionalTests.cs
@@ -0,0 +1,242 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Modbus;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Additional unit tests for covering
+ /// type aliases and byte / word order combinations not exercised by
+ /// the baseline test file.
+ ///
+ [TestFixture]
+ public sealed class ModbusDataConverterAdditionalTests
+ {
+ [TestCase("ushort", 1)]
+ [TestCase("uint", 2)]
+ [TestCase("float", 2)]
+ [TestCase("int16", 1)]
+ [TestCase("uint16", 1)]
+ [TestCase("int32", 2)]
+ [TestCase("uint32", 2)]
+ [TestCase("float32", 2)]
+ [TestCase("int64", 4)]
+ [TestCase("uint64", 4)]
+ [TestCase("float64", 4)]
+ public void RegisterCountRecognizesCanonicalAndAliasNames(string type, int expected)
+ {
+ Assert.That(ModbusDataConverter.RegisterCount(type), Is.EqualTo(expected));
+ }
+
+ [TestCase("int16", (short)-1000)]
+ [TestCase("uint16", (ushort)50000)]
+ public void Int16RoundTripsWithLsbFirstByteOrder(string type, object value)
+ {
+ Variant input = value is short s ? new Variant(s) : new Variant((ushort)value);
+ ushort[] registers = ModbusDataConverter.ToRegisters(input, type, msbFirst: false, mswFirst: true);
+ Variant actual = ModbusDataConverter.ToVariant(registers, type, msbFirst: false, mswFirst: true);
+
+ Assert.That(
+ Convert.ToDouble(actual.AsBoxedObject(), CultureInfo.InvariantCulture),
+ Is.EqualTo(Convert.ToDouble(value, CultureInfo.InvariantCulture)).Within(0.001));
+ }
+
+ [TestCase(true, true)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ [TestCase(false, false)]
+ public void Uint32RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst)
+ {
+ const uint expected = 0xDEADBEEFu;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "uint32", msbFirst, mswFirst);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "uint32", msbFirst, mswFirst);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [TestCase(true, true)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ [TestCase(false, false)]
+ public void Float32RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst)
+ {
+ const float expected = -123.456f;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "float32", msbFirst, mswFirst);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "float32", msbFirst, mswFirst);
+
+ Assert.That(
+ System.Convert.ToSingle(actual.AsBoxedObject(), CultureInfo.InvariantCulture),
+ Is.EqualTo(expected).Within(0.001f));
+ }
+
+ [TestCase(true, true)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ [TestCase(false, false)]
+ public void Int64RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst)
+ {
+ const long expected = -9876543210L;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "int64", msbFirst, mswFirst);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "int64", msbFirst, mswFirst);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [TestCase(true, true)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ [TestCase(false, false)]
+ public void Uint64RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst)
+ {
+ const ulong expected = 0xFEDCBA9876543210uL;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "uint64", msbFirst, mswFirst);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "uint64", msbFirst, mswFirst);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [TestCase(true, true)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ [TestCase(false, false)]
+ public void Float64RoundTripsAcrossAllByteWordOrders(bool msbFirst, bool mswFirst)
+ {
+ const double expected = 3.141592653589793;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "float64", msbFirst, mswFirst);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "float64", msbFirst, mswFirst);
+
+ Assert.That(
+ System.Convert.ToDouble(actual.AsBoxedObject(), CultureInfo.InvariantCulture),
+ Is.EqualTo(expected).Within(1e-10));
+ }
+
+ [Test]
+ public void Uint16ViaUshortAliasRoundTrips()
+ {
+ const ushort expected = 12345;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "ushort", msbFirst: true, mswFirst: true);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "ushort", msbFirst: true, mswFirst: true);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Uint32ViaUintAliasRoundTrips()
+ {
+ const uint expected = 987654321u;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "uint", msbFirst: true, mswFirst: true);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "uint", msbFirst: true, mswFirst: true);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void Float32ViaFloatAliasRoundTrips()
+ {
+ const float expected = 9.99f;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), "float", msbFirst: true, mswFirst: true);
+ Variant actual = ModbusDataConverter.ToVariant(registers, "float", msbFirst: true, mswFirst: true);
+
+ Assert.That(
+ System.Convert.ToSingle(actual.AsBoxedObject(), CultureInfo.InvariantCulture),
+ Is.EqualTo(expected).Within(0.001f));
+ }
+
+ [Test]
+ public void DefaultTypeIsUint16WhenTypeIsNull()
+ {
+ const ushort expected = 42;
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected), null!, msbFirst: true, mswFirst: true);
+ Variant actual = ModbusDataConverter.ToVariant(registers, null!, msbFirst: true, mswFirst: true);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public void ToVariantThrowsWhenRegisterCountIsTooSmallForInt64()
+ {
+ ModbusException ex = Assert.Throws(
+ () => ModbusDataConverter.ToVariant([0x1234, 0x5678], "int64",
+ msbFirst: true, mswFirst: true))!;
+
+ Assert.That(ex.Message, Does.Contain("requires 4 registers"));
+ }
+
+ [Test]
+ public void ToVariantThrowsWhenRegisterCountIsTooSmallForFloat32()
+ {
+ ModbusException ex = Assert.Throws(
+ () => ModbusDataConverter.ToVariant([0x1234], "float32",
+ msbFirst: true, mswFirst: true))!;
+
+ Assert.That(ex.Message, Does.Contain("requires 2 registers"));
+ }
+
+ [Test]
+ public void Int16LsbFirstAndMsbFirstProduceDifferentRegisters()
+ {
+ Variant value = new Variant((short)0x1234);
+
+ ushort[] msb = ModbusDataConverter.ToRegisters(value, "int16", msbFirst: true, mswFirst: true);
+ ushort[] lsb = ModbusDataConverter.ToRegisters(value, "int16", msbFirst: false, mswFirst: true);
+
+ // MSB first: high byte in high bits of register word.
+ // LSB first: bytes are swapped within the register.
+ Assert.That(msb[0], Is.Not.EqualTo(lsb[0]));
+ }
+
+ [Test]
+ public void Int32MswFirstAndLswFirstProduceDifferentRegisterOrder()
+ {
+ const int value = 0x12345678;
+
+ ushort[] msw = ModbusDataConverter.ToRegisters(
+ new Variant(value), "int32", msbFirst: true, mswFirst: true);
+ ushort[] lsw = ModbusDataConverter.ToRegisters(
+ new Variant(value), "int32", msbFirst: true, mswFirst: false);
+
+ // The register order should be reversed.
+ Assert.That(msw[0], Is.EqualTo(lsw[1]));
+ Assert.That(msw[1], Is.EqualTo(lsw[0]));
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs
new file mode 100644
index 0000000000..5b1cd77a97
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusDataConverterTests.cs
@@ -0,0 +1,127 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Globalization;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Modbus;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ [TestFixture]
+ public sealed class ModbusDataConverterTests
+ {
+ [TestCase("short", 1)]
+ [TestCase("word", 1)]
+ [TestCase("int", 2)]
+ [TestCase("dword", 2)]
+ [TestCase("single", 2)]
+ [TestCase("long", 4)]
+ [TestCase("ulong", 4)]
+ [TestCase("double", 4)]
+ [TestCase("unknown", 1)]
+ [TestCase(null, 1)]
+ public void RegisterCountRecognizesAliases(string? type, int expected)
+ {
+ Assert.That(ModbusDataConverter.RegisterCount(type!), Is.EqualTo(expected));
+ }
+
+ [TestCase(true, true)]
+ [TestCase(true, false)]
+ [TestCase(false, true)]
+ [TestCase(false, false)]
+ public void Int32RoundTripsAcrossByteAndWordOrders(bool msbFirst, bool mswFirst)
+ {
+ const int expected = 0x12345678;
+
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ new Variant(expected),
+ "int32",
+ msbFirst,
+ mswFirst);
+ var actual = ModbusDataConverter.ToVariant(
+ registers,
+ "int32",
+ msbFirst,
+ mswFirst);
+
+ Assert.That(actual.AsBoxedObject(), Is.EqualTo(expected));
+ }
+
+ [TestCase("int16", -1234.0)]
+ [TestCase("uint16", 54321.0)]
+ [TestCase("uint32", 305419896.0)]
+ [TestCase("float32", 123.5)]
+ [TestCase("int64", -1234567890123.0)]
+ [TestCase("uint64", 1234567890123.0)]
+ [TestCase("float64", 123456.75)]
+ [TestCase("unknown", 42.0)]
+ public void NumericTypesRoundTrip(string type, double expected)
+ {
+ Variant input = type switch
+ {
+ "int16" => new Variant(Convert.ToInt16(expected)),
+ "uint16" or "unknown" => new Variant(Convert.ToUInt16(expected)),
+ "uint32" => new Variant(Convert.ToUInt32(expected)),
+ "float32" => new Variant(Convert.ToSingle(expected)),
+ "int64" => new Variant(Convert.ToInt64(expected)),
+ "uint64" => new Variant(Convert.ToUInt64(expected)),
+ _ => new Variant(expected)
+ };
+
+ ushort[] registers = ModbusDataConverter.ToRegisters(
+ input,
+ type,
+ msbFirst: true,
+ mswFirst: true);
+ var actual = ModbusDataConverter.ToVariant(
+ registers,
+ type,
+ msbFirst: true,
+ mswFirst: true);
+
+ Assert.That(
+ Convert.ToDouble(actual.AsBoxedObject(), CultureInfo.InvariantCulture),
+ Is.EqualTo(expected).Within(0.001));
+ }
+
+ [Test]
+ public void ToVariantRejectsInsufficientRegisters()
+ {
+ ModbusException exception = Assert.Throws(
+ () => ModbusDataConverter.ToVariant(
+ [0x1234],
+ "int32",
+ msbFirst: true,
+ mswFirst: true))!;
+
+ Assert.That(exception.Message, Does.Contain("requires 2 registers"));
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs
new file mode 100644
index 0000000000..4ec83b1baf
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusTcpClientHardeningTests.cs
@@ -0,0 +1,407 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Modbus;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Hardening tests for : hostile / truncated
+ /// responses must map to a (never an out-of-range
+ /// index), and a timeout must fault the connection so a fresh reconnect is
+ /// required and works deterministically.
+ ///
+ [TestFixture]
+ public sealed class ModbusTcpClientHardeningTests
+ {
+ [Test]
+ public async Task TruncatedRegisterResponseThrowsModbusException()
+ {
+ // A register-read response whose declared byte count (4) exceeds the
+ // register bytes actually present in the frame. Before the bounds
+ // check this indexed out of range; now it maps to a ModbusException.
+ using var server = new ScriptedModbusServer((_, pdu) =>
+ pdu[0] == 0x03
+ ? [0x03, 0x04, 0x00, 0x2A] // byteCount 4, only 1 register present
+ : null);
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(1, 0, 2, CancellationToken.None).ConfigureAwait(false));
+ }
+
+ [Test]
+ public async Task TruncatedBitResponseThrowsModbusException()
+ {
+ // A coil-read response claiming a byte count (2) larger than the frame
+ // carries, so a naive read would index past the end of the buffer.
+ using var server = new ScriptedModbusServer((_, pdu) =>
+ pdu[0] == 0x01
+ ? [0x01, 0x02, 0x01] // byteCount 2, only 1 data byte present
+ : null);
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ Assert.ThrowsAsync(async () =>
+ await client.ReadCoilsAsync(1, 0, 9, CancellationToken.None).ConfigureAwait(false));
+ }
+
+ [Test]
+ public async Task TimeoutFaultsConnectionThenNextOperationReconnects()
+ {
+ using var server = new ScriptedModbusServer((connection, pdu) =>
+ {
+ // First connection: never respond so the client times out. Second
+ // (reconnect) connection: answer the register read normally.
+ if (connection == 0)
+ {
+ return null;
+ }
+ return [0x03, 0x02, 0x12, 0x34];
+ });
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromMilliseconds(300));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ // The silent server causes the request to time out.
+ Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+
+ // The connection is now faulted, so the next operation opens a fresh
+ // socket itself instead of requiring activation to run again.
+ ushort[] registers = await client
+ .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false);
+ Assert.That(registers, Has.Length.EqualTo(1));
+ Assert.That(registers[0], Is.EqualTo((ushort)0x1234));
+ }
+
+ [Test]
+ public async Task DroppedConnectionReconnectsOncePerOperationAndRecovers()
+ {
+ using var server = new TestModbusServer();
+ server.HoldingRegisters[0] = 0x1234;
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ushort[] first = await client
+ .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false);
+ Assert.That(first, Is.EqualTo(new ushort[] { 0x1234 }));
+
+ server.RejectConnections = true;
+ server.DisconnectClients();
+
+ Exception? dropped = Assert.CatchAsync(async () =>
+ await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+ Assert.That(dropped, Is.TypeOf().Or.TypeOf());
+
+ int acceptedBeforeReconnect = server.AcceptedConnectionCount;
+ Exception? rejected = Assert.CatchAsync(async () =>
+ await client.ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+ Assert.That(rejected, Is.TypeOf().Or.TypeOf());
+ Assert.That(
+ server.AcceptedConnectionCount - acceptedBeforeReconnect,
+ Is.EqualTo(1),
+ "A single failed operation must not loop and open a storm of reconnect sockets.");
+
+ server.RejectConnections = false;
+ server.HoldingRegisters[0] = 0x5678;
+
+ ushort[] recovered = await client
+ .ReadHoldingRegistersAsync(1, 0, 1, CancellationToken.None).ConfigureAwait(false);
+ Assert.That(recovered, Is.EqualTo(new ushort[] { 0x5678 }));
+ }
+
+ [Test]
+ public async Task WriteMultipleCoilsAcceptsProtocolMaximum()
+ {
+ using var server = new TestModbusServer();
+ bool[] values = new bool[1968];
+ values[0] = true;
+ values[7] = true;
+ values[8] = true;
+ values[^1] = true;
+
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+ await client
+ .WriteMultipleCoilsAsync(1, 0, values, CancellationToken.None)
+ .ConfigureAwait(false);
+
+ Assert.That(server.LastFunctionCode, Is.EqualTo(0x0F));
+ Assert.That(server.Coils.Take(values.Length), Is.EqualTo(values));
+ }
+
+ [Test]
+ public void WriteMultipleCoilsRejectsQuantityAboveProtocolMaximum()
+ {
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", 502, TimeSpan.FromSeconds(2));
+
+ ArgumentOutOfRangeException? exception = Assert.ThrowsAsync(
+ async () => await client
+ .WriteMultipleCoilsAsync(1, 0, new bool[1969], CancellationToken.None)
+ .ConfigureAwait(false));
+ Assert.That(exception!.Message, Does.Contain("1968"));
+ }
+
+ [TestCaseSource(nameof(InvalidSingleCoilAcknowledgements))]
+ public async Task WriteSingleCoilRejectsInvalidAcknowledgement(
+ byte[] acknowledgement,
+ string expectedError)
+ {
+ using var server = new ScriptedModbusServer((_, pdu) =>
+ pdu[0] == 0x05 ? acknowledgement : null);
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ModbusException? exception = Assert.ThrowsAsync(
+ async () => await client
+ .WriteSingleCoilAsync(1, 0, true, CancellationToken.None)
+ .ConfigureAwait(false));
+ Assert.That(exception!.Message, Does.Contain(expectedError));
+ }
+
+ [TestCaseSource(nameof(InvalidMultipleCoilAcknowledgements))]
+ public async Task WriteMultipleCoilsRejectsInvalidAcknowledgement(
+ byte[] acknowledgement,
+ string expectedError)
+ {
+ using var server = new ScriptedModbusServer((_, pdu) =>
+ pdu[0] == 0x0F ? acknowledgement : null);
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ModbusException? exception = Assert.ThrowsAsync(
+ async () => await client
+ .WriteMultipleCoilsAsync(1, 0, [true, false], CancellationToken.None)
+ .ConfigureAwait(false));
+ Assert.That(exception!.Message, Does.Contain(expectedError));
+ }
+
+ private static IEnumerable InvalidSingleCoilAcknowledgements()
+ {
+ yield return new TestCaseData(
+ new byte[] { 0x05, 0x00, 0x00, 0xFF },
+ "exactly 5 bytes")
+ .SetName("WriteSingleCoilRejectsTruncatedAcknowledgement");
+ yield return new TestCaseData(
+ new byte[] { 0x05, 0x00, 0x00, 0xFF, 0x00, 0x00 },
+ "exactly 5 bytes")
+ .SetName("WriteSingleCoilRejectsOversizedAcknowledgement");
+ yield return new TestCaseData(
+ new byte[] { 0x05, 0x00, 0x01, 0xFF, 0x00 },
+ "requested address")
+ .SetName("WriteSingleCoilRejectsMismatchedAddress");
+ yield return new TestCaseData(
+ new byte[] { 0x05, 0x00, 0x00, 0x00, 0x00 },
+ "requested value")
+ .SetName("WriteSingleCoilRejectsMismatchedValue");
+ }
+
+ private static IEnumerable InvalidMultipleCoilAcknowledgements()
+ {
+ yield return new TestCaseData(
+ new byte[] { 0x0F, 0x00, 0x00, 0x00 },
+ "exactly 5 bytes")
+ .SetName("WriteMultipleCoilsRejectsTruncatedAcknowledgement");
+ yield return new TestCaseData(
+ new byte[] { 0x0F, 0x00, 0x00, 0x00, 0x02, 0x00 },
+ "exactly 5 bytes")
+ .SetName("WriteMultipleCoilsRejectsOversizedAcknowledgement");
+ yield return new TestCaseData(
+ new byte[] { 0x0F, 0x00, 0x01, 0x00, 0x02 },
+ "requested address")
+ .SetName("WriteMultipleCoilsRejectsMismatchedAddress");
+ yield return new TestCaseData(
+ new byte[] { 0x0F, 0x00, 0x00, 0x00, 0x03 },
+ "requested value or quantity")
+ .SetName("WriteMultipleCoilsRejectsMismatchedQuantity");
+ }
+
+ ///
+ /// A minimal Modbus TCP listener whose per-request response is supplied by
+ /// a script. The script receives the zero-based accepted-connection index
+ /// and the request PDU and returns the response PDU, or null to hold
+ /// the connection open without responding (used to provoke a timeout).
+ ///
+ private sealed class ScriptedModbusServer : IDisposable
+ {
+ public ScriptedModbusServer(Func responder)
+ {
+ m_responder = responder;
+ m_listener = new TcpListener(IPAddress.Loopback, 0);
+ m_listener.Start();
+ Port = ((IPEndPoint)m_listener.LocalEndpoint).Port;
+ m_loop = Task.Run(AcceptLoopAsync);
+ }
+
+ public int Port { get; }
+
+ public void Dispose()
+ {
+ m_cts.Cancel();
+ m_listener.Stop();
+ m_listener.Dispose();
+ try
+ {
+ m_loop.Wait(2000);
+ }
+ catch (AggregateException)
+ {
+ // Ignore teardown faults.
+ }
+ m_cts.Dispose();
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ while (!m_cts.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+ catch (SocketException)
+ {
+ return;
+ }
+ int connection = m_connections++;
+ _ = Task.Run(() => ServeAsync(client, connection));
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client, int connection)
+ {
+ using (client)
+ using (NetworkStream stream = client.GetStream())
+ {
+ try
+ {
+ while (!m_cts.IsCancellationRequested)
+ {
+ byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false);
+ if (header is null)
+ {
+ return;
+ }
+ int length = (header[4] << 8) | header[5];
+ byte[]? rest = await ReadExactAsync(stream, length).ConfigureAwait(false);
+ if (rest is null)
+ {
+ return;
+ }
+ byte unit = rest[0];
+ byte[] pdu = new byte[rest.Length - 1];
+ Array.Copy(rest, 1, pdu, 0, pdu.Length);
+
+ byte[]? responsePdu = m_responder(connection, pdu);
+ if (responsePdu is null)
+ {
+ // Hold the connection open without answering so the
+ // client's request times out.
+ await Task.Delay(Timeout.Infinite, m_cts.Token).ConfigureAwait(false);
+ return;
+ }
+
+ byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu);
+ await stream.WriteAsync(frame).ConfigureAwait(false);
+ await stream.FlushAsync().ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Server shutting down.
+ }
+ catch (System.IO.IOException)
+ {
+ // Client disconnected.
+ }
+ }
+ }
+
+ private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu)
+ {
+ int length = pdu.Length + 1;
+ byte[] frame = new byte[7 + pdu.Length];
+ frame[0] = txnHi;
+ frame[1] = txnLo;
+ frame[2] = 0x00;
+ frame[3] = 0x00;
+ frame[4] = (byte)(length >> 8);
+ frame[5] = (byte)(length & 0xFF);
+ frame[6] = unit;
+ Array.Copy(pdu, 0, frame, 7, pdu.Length);
+ return frame;
+ }
+
+ private static async Task ReadExactAsync(NetworkStream stream, int count)
+ {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false);
+ if (read == 0)
+ {
+ return null;
+ }
+ offset += read;
+ }
+ return buffer;
+ }
+
+ private readonly Func m_responder;
+ private readonly TcpListener m_listener;
+ private readonly Task m_loop;
+ private readonly CancellationTokenSource m_cts = new();
+ private int m_connections;
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs
new file mode 100644
index 0000000000..12338609f4
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotBindingChannelTests.cs
@@ -0,0 +1,834 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Modbus;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Tests for ModbusWotBindingChannel (entity dispatch, error mapping,
+ /// polling subscriptions, disposal) and additional ModbusTcpClient
+ /// protocol paths (FC 02, FC 0x0F, exception responses, transaction-id mismatch,
+ /// zero-length responses, unexpected function codes, and not-connected faults).
+ ///
+ [TestFixture]
+ public sealed class ModbusWotBindingChannelTests
+ {
+ private static WotProtocolBinderRegistry Registry()
+ {
+ return new WotProtocolBinderRegistry(
+ [new ModbusBindingPlanner()],
+ [new ModbusWotBindingExecutor(new ModbusWotBindingOptions
+ {
+ ObserveInterval = TimeSpan.FromMilliseconds(100)
+ })],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td)
+ {
+ return registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ }
+
+ private static string RegisterTd(int port, string entity, int address, int quantity, string type)
+ {
+ return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ port.ToString(CultureInfo.InvariantCulture) + "/1\",\"modv:entity\":\"" + entity +
+ "\",\"modv:address\":" + address.ToString(CultureInfo.InvariantCulture) +
+ ",\"modv:quantity\":" + quantity.ToString(CultureInfo.InvariantCulture) +
+ ",\"modv:type\":\"" + type + "\"}]}}}";
+ }
+
+ private static string BooleanTd(int port, string entity, int address)
+ {
+ return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"boolean\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ port.ToString(CultureInfo.InvariantCulture) + "/1\",\"modv:entity\":\"" + entity +
+ "\",\"modv:address\":" + address.ToString(CultureInfo.InvariantCulture) +
+ ",\"modv:quantity\":1}]}}}";
+ }
+
+ private static WotCompiledForm BuildRawForm(
+ int port, string entity, WoTBindingCapabilityEnum capability, string opName)
+ {
+ var addressing = new WotAddressingDescriptor(
+ entity + ":0:1@1",
+ ImmutableDictionary.Empty
+ .Add("entity", entity)
+ .Add("address", "0")
+ .Add("quantity", "1")
+ .Add("unitId", "1"));
+ var payload = new WotPayloadDescriptor(
+ "application/octet-stream", "octet-stream",
+ ImmutableDictionary.Empty
+ .Add("type", "uint16")
+ .Add("mostSignificantByte", "true")
+ .Add("mostSignificantWord", "true"));
+ return new WotCompiledForm(
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri),
+ WotAffordanceKind.Property, "p", "/properties/p/forms/0",
+ capability, opName,
+ new WotEndpointDescriptor(
+ "modbus+tcp", "127.0.0.1", port,
+ "modbus+tcp://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture)),
+ addressing,
+ new WotOperationDescriptor(capability, opName, opName),
+ payload,
+ [], isExecutable: true);
+ }
+
+ private static async Task WaitForNotificationAsync(
+ ConcurrentQueue queue, int maxAttempts = 100)
+ {
+ for (int i = 0; i < maxAttempts; i++)
+ {
+ if (!queue.IsEmpty)
+ {
+ return true;
+ }
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ return false;
+ }
+
+ [Test]
+ public async Task ModbusChannelReadsInputRegisterEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ server.InputRegisters[5] = 0xABCD;
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "inputRegister", 5, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo((ushort)0xABCD));
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelReadsDiscreteInputEndToEnd()
+ {
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x02)
+ {
+ return [0x02, 0x01, 0x01];
+ }
+ return [];
+ });
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, BooleanTd(server.Port, "discreteInput", 0));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.True);
+ }
+ }
+
+ [Test]
+ public void ModbusChannelWriteToDiscreteInputIsRejectedDuringActivation()
+ {
+ using var server = new TestModbusServer();
+ WotCompiledForm form = BuildRawForm(
+ server.Port, "discreteInput", WoTBindingCapabilityEnum.WriteProperty, "writeproperty");
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusChannelWriteToInputRegisterIsRejectedDuringActivation()
+ {
+ using var server = new TestModbusServer();
+ WotCompiledForm form = BuildRawForm(
+ server.Port, "inputRegister", WoTBindingCapabilityEnum.WriteProperty, "writeproperty");
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ }
+
+ [Test]
+ public async Task ModbusChannelInvokeAsyncReturnsBadNotSupported()
+ {
+ using var server = new TestModbusServer();
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNotSupported));
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelObserveAsyncReceivesNotification()
+ {
+ using var server = new TestModbusServer();
+ server.HoldingRegisters[0] = 0x0042;
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ var received = new ConcurrentQueue();
+ IWotSubscription sub = await channel.ObserveAsync(n => received.Enqueue(n))
+ .ConfigureAwait(false);
+ await using (sub.ConfigureAwait(false))
+ {
+ bool got = await WaitForNotificationAsync(received).ConfigureAwait(false);
+ Assert.That(got, Is.True, "The observe subscription should deliver at least one notification.");
+ }
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelSubscribeEventAsyncReceivesNotification()
+ {
+ using var server = new TestModbusServer();
+ server.HoldingRegisters[0] = 0x0007;
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ var received = new ConcurrentQueue();
+ IWotSubscription sub = await channel.SubscribeEventAsync(n => received.Enqueue(n))
+ .ConfigureAwait(false);
+ await using (sub.ConfigureAwait(false))
+ {
+ bool got = await WaitForNotificationAsync(received).ConfigureAwait(false);
+ Assert.That(got, Is.True,
+ "SubscribeEventAsync should delegate to ObserveAsync and deliver notifications.");
+ }
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelDisposeAsyncIsIdempotent()
+ {
+ using var server = new TestModbusServer();
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+
+ // First dispose should succeed.
+ await channel.DisposeAsync().ConfigureAwait(false);
+
+ // Second dispose must not throw.
+ Assert.DoesNotThrowAsync(
+ async () => await channel.DisposeAsync().ConfigureAwait(false));
+ }
+
+ [Test]
+ public async Task ModbusChannelReadMapsModbusExceptionToStatusCode()
+ {
+ // Server returns Modbus exception 0x02 (illegal data address) for FC 03.
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x03)
+ {
+ return [0x83, 0x02];
+ }
+ return [];
+ });
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ // Exception code 0x02 maps to BadNodeIdUnknown via ModbusStatusMapper.
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdUnknown));
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelReadMapsTimeoutToBadTimeout()
+ {
+ // Server holds connection without responding on first request (timeout).
+ int connectionCount = 0;
+ using var server = new EphemeralModbusServer((conn, _) =>
+ {
+ int c = Interlocked.Increment(ref connectionCount);
+ // Null = hold the connection open forever (provoke client timeout).
+ return c <= 0 ? null : null;
+ });
+
+ // Use a very short timeout so the test completes quickly.
+ var options = new ModbusWotBindingOptions { ObserveInterval = TimeSpan.FromSeconds(1) };
+ var smallBounds = new WotBindingBounds { DefaultTimeout = TimeSpan.FromMilliseconds(300) };
+ var registry = new WotProtocolBinderRegistry(
+ [new ModbusBindingPlanner()],
+ [new ModbusWotBindingExecutor(options)],
+ bounds: smallBounds,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTimeout));
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelWriteMapsModbusExceptionToStatusCode()
+ {
+ // Server returns Modbus exception 0x03 (illegal data value) for FC 06.
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x06)
+ {
+ return [0x86, 0x03];
+ }
+ return [pdu[0], pdu[1], pdu[2], pdu[3], pdu[4]];
+ });
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant((ushort)42))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ // Exception code 0x03 maps to BadInvalidArgument via ModbusStatusMapper.
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelWriteMapsTimeoutToBadTimeout()
+ {
+ using var server = new EphemeralModbusServer((_, _2) => null);
+
+ var options = new ModbusWotBindingOptions { ObserveInterval = TimeSpan.FromSeconds(1) };
+ var smallBounds = new WotBindingBounds { DefaultTimeout = TimeSpan.FromMilliseconds(300) };
+ var registry = new WotProtocolBinderRegistry(
+ [new ModbusBindingPlanner()],
+ [new ModbusWotBindingExecutor(options)],
+ bounds: smallBounds,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 0, 1, "uint16"));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant((ushort)1))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTimeout));
+ }
+ }
+
+ [Test]
+ public async Task ModbusChannelWriteMultipleRegistersEndToEnd()
+ {
+ using var server = new TestModbusServer();
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, RegisterTd(server.Port, "holdingRegister", 10, 2, "int32"));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant(0x12345678))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(server.HoldingRegisters[10], Is.EqualTo((ushort)0x1234));
+ Assert.That(server.HoldingRegisters[11], Is.EqualTo((ushort)0x5678));
+ }
+ }
+
+ [Test]
+ public async Task ModbusTcpClientReadsDiscreteInputsSuccessfully()
+ {
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x02)
+ {
+ // 3 discrete inputs, bits 1 and 3 set (0b00001010 = 0x0A).
+ return [0x02, 0x01, 0x0A];
+ }
+ return [];
+ });
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ bool[] bits = await client.ReadDiscreteInputsAsync(
+ 1, 0, 3, CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(bits, Has.Length.EqualTo(3));
+ Assert.That(bits[0], Is.False); // bit 0 of 0x0A
+ Assert.That(bits[1], Is.True); // bit 1 of 0x0A
+ Assert.That(bits[2], Is.False); // bit 2 of 0x0A
+ }
+
+ [Test]
+ public async Task ModbusTcpClientWritesMultipleCoilsSuccessfully()
+ {
+ byte[]? capturedPdu = null;
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x0F)
+ {
+ capturedPdu = pdu;
+ // Echo back: FC, addrHi, addrLo, qtyHi, qtyLo.
+ return [0x0F, pdu[1], pdu[2], pdu[3], pdu[4]];
+ }
+ return [];
+ });
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ await client.WriteMultipleCoilsAsync(
+ 1, 0, [true, false, true], CancellationToken.None).ConfigureAwait(false);
+
+ Assert.That(capturedPdu, Is.Not.Null);
+ Assert.That(capturedPdu![0], Is.EqualTo((byte)0x0F), "Function code must be 0x0F.");
+ // 3 coils packed into 1 byte: true=1, false=0, true=1 → 0b00000101 = 0x05.
+ Assert.That(capturedPdu[6], Is.EqualTo((byte)0x05), "Packed coil byte must encode correct bit pattern.");
+ }
+
+ [Test]
+ public async Task ModbusTcpClientExceptionResponseThrowsWithCorrectCode()
+ {
+ // Server returns Modbus exception 0x04 (server device failure) for FC 03.
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x03)
+ {
+ return [0x83, 0x04];
+ }
+ return [];
+ });
+
+ using var client = new ModbusTcpClient("127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ModbusException? ex = Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(
+ 1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+
+ Assert.That(ex!.ExceptionCode, Is.EqualTo((byte)0x04));
+ }
+
+ [Test]
+ public void ModbusTcpClientNotConnectedThrowsModbusException()
+ {
+ // Never call ConnectAsync — the client is not connected.
+ using var client = new ModbusTcpClient("127.0.0.1", 1, TimeSpan.FromSeconds(1));
+
+ ModbusException? ex = Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(
+ 1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+
+ Assert.That(ex!.Message, Does.Contain("not connected").IgnoreCase);
+ }
+
+ [Test]
+ public async Task ModbusTcpClientTransactionIdMismatchThrowsModbusException()
+ {
+ // Server always replies with transaction ID 0x00 0x00, never echoing
+ // the client's actual transaction ID.
+ using var server = new BadTxnModbusServer();
+
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ModbusException? ex = Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(
+ 1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+
+ Assert.That(ex!.Message, Does.Contain("transaction").IgnoreCase);
+ }
+
+ [Test]
+ public async Task ModbusTcpClientZeroLengthResponseThrowsModbusException()
+ {
+ // Server returns an empty PDU [], which builds a frame with length=1
+ // (unit byte only, no PDU bytes). The client reads responseLength=0 < 1
+ // and must throw.
+ using var server = new EphemeralModbusServer((_, _2) => []);
+
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ModbusException? ex = Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(
+ 1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+
+ Assert.That(ex!.Message, Does.Contain("invalid").IgnoreCase.Or.Contain("length").IgnoreCase);
+ }
+
+ [Test]
+ public async Task ModbusTcpClientUnexpectedFunctionCodeThrowsModbusException()
+ {
+ // Server replies to FC 0x03 with FC 0x04 (not an exception, just the wrong function).
+ using var server = new EphemeralModbusServer((_, pdu) =>
+ {
+ if (pdu.Length >= 1 && pdu[0] == 0x03)
+ {
+ // Valid input-register response format but wrong FC.
+ return [0x04, 0x02, 0xAB, 0xCD];
+ }
+ return [];
+ });
+
+ using var client = new ModbusTcpClient(
+ "127.0.0.1", server.Port, TimeSpan.FromSeconds(2));
+ await client.ConnectAsync(CancellationToken.None).ConfigureAwait(false);
+
+ ModbusException? ex = Assert.ThrowsAsync(async () =>
+ await client.ReadHoldingRegistersAsync(
+ 1, 0, 1, CancellationToken.None).ConfigureAwait(false));
+
+ Assert.That(ex!.Message, Does.Contain("Unexpected").IgnoreCase);
+ }
+
+ ///
+ /// A minimal scripted Modbus TCP server. The responder receives the
+ /// connection index and request PDU and returns the response PDU (or
+ /// null to hold the connection without responding).
+ ///
+ private sealed class EphemeralModbusServer : IDisposable
+ {
+ public EphemeralModbusServer(Func responder)
+ {
+ m_responder = responder;
+ m_listener = new TcpListener(IPAddress.Loopback, 0);
+ m_listener.Start();
+ Port = ((IPEndPoint)m_listener.LocalEndpoint).Port;
+ m_loop = Task.Run(AcceptLoopAsync);
+ }
+
+ public int Port { get; }
+
+ public void Dispose()
+ {
+ m_cts.Cancel();
+ m_listener.Stop();
+ m_listener.Dispose();
+ try
+ {
+ m_loop.Wait(2000);
+ }
+ catch (AggregateException)
+ {
+ // Ignore teardown faults.
+ }
+ m_cts.Dispose();
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ while (!m_cts.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+ catch (SocketException)
+ {
+ return;
+ }
+ int conn = m_connections++;
+ _ = Task.Run(() => ServeAsync(client, conn));
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client, int connection)
+ {
+ using (client)
+ using (NetworkStream stream = client.GetStream())
+ {
+ try
+ {
+ while (!m_cts.IsCancellationRequested)
+ {
+ byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false);
+ if (header is null)
+ {
+ return;
+ }
+ int len = (header[4] << 8) | header[5];
+ byte[]? rest = await ReadExactAsync(stream, len).ConfigureAwait(false);
+ if (rest is null)
+ {
+ return;
+ }
+ byte unit = rest[0];
+ byte[] pdu = new byte[rest.Length - 1];
+ Array.Copy(rest, 1, pdu, 0, pdu.Length);
+
+ byte[]? responsePdu = m_responder(connection, pdu);
+ if (responsePdu is null)
+ {
+ await Task.Delay(Timeout.Infinite, m_cts.Token).ConfigureAwait(false);
+ return;
+ }
+
+ byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu);
+ await stream.WriteAsync(frame).ConfigureAwait(false);
+ await stream.FlushAsync().ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu)
+ {
+ int length = pdu.Length + 1;
+ byte[] frame = new byte[7 + pdu.Length];
+ frame[0] = txnHi;
+ frame[1] = txnLo;
+ frame[2] = 0x00;
+ frame[3] = 0x00;
+ frame[4] = (byte)(length >> 8);
+ frame[5] = (byte)(length & 0xFF);
+ frame[6] = unit;
+ Array.Copy(pdu, 0, frame, 7, pdu.Length);
+ return frame;
+ }
+
+ private static async Task ReadExactAsync(NetworkStream stream, int count)
+ {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = await stream
+ .ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false);
+ if (read == 0)
+ {
+ return null;
+ }
+ offset += read;
+ }
+ return buffer;
+ }
+
+ private readonly Func m_responder;
+ private readonly TcpListener m_listener;
+ private readonly Task m_loop;
+ private readonly CancellationTokenSource m_cts = new();
+ private int m_connections;
+ }
+
+ ///
+ /// A Modbus TCP server that always replies with transaction ID 0x0000,
+ /// deliberately mismatching any client request to trigger the
+ /// transaction-id-mismatch fault path.
+ ///
+ private sealed class BadTxnModbusServer : IDisposable
+ {
+ public BadTxnModbusServer()
+ {
+ m_listener = new TcpListener(IPAddress.Loopback, 0);
+ m_listener.Start();
+ Port = ((IPEndPoint)m_listener.LocalEndpoint).Port;
+ m_loop = Task.Run(AcceptLoopAsync);
+ }
+
+ public int Port { get; }
+
+ public void Dispose()
+ {
+ m_cts.Cancel();
+ m_listener.Stop();
+ m_listener.Dispose();
+ try
+ {
+ m_loop.Wait(2000);
+ }
+ catch (AggregateException)
+ {
+ }
+ m_cts.Dispose();
+ }
+
+ private async Task AcceptLoopAsync()
+ {
+ while (!m_cts.IsCancellationRequested)
+ {
+ TcpClient client;
+ try
+ {
+ client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false);
+ }
+ catch (ObjectDisposedException)
+ {
+ return;
+ }
+ catch (SocketException)
+ {
+ return;
+ }
+ _ = Task.Run(() => ServeAsync(client));
+ }
+ }
+
+ private async Task ServeAsync(TcpClient client)
+ {
+ using (client)
+ using (NetworkStream stream = client.GetStream())
+ {
+ try
+ {
+ // Read the request but always reply with txn ID 0x0000.
+ byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false);
+ if (header is null)
+ {
+ return;
+ }
+ int len = (header[4] << 8) | header[5];
+ byte[]? rest = await ReadExactAsync(stream, len).ConfigureAwait(false);
+ if (rest is null)
+ {
+ return;
+ }
+ byte unit = rest[0];
+ // Send a valid holding-register read response but with txn ID 0x00 0x00.
+ byte[] response = [0x00, 0x00, 0x00, 0x00, 0x00, 0x05, unit,
+ 0x03, 0x02, 0x00, 0x00];
+ await stream.WriteAsync(response).ConfigureAwait(false);
+ await stream.FlushAsync().ConfigureAwait(false);
+ }
+ catch (IOException)
+ {
+ }
+ }
+ }
+
+ private static async Task ReadExactAsync(NetworkStream stream, int count)
+ {
+ byte[] buffer = new byte[count];
+ int offset = 0;
+ while (offset < count)
+ {
+ int read = await stream
+ .ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false);
+ if (read == 0)
+ {
+ return null;
+ }
+ offset += read;
+ }
+ return buffer;
+ }
+
+ private readonly TcpListener m_listener;
+ private readonly Task m_loop;
+ private readonly CancellationTokenSource m_cts = new();
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs
new file mode 100644
index 0000000000..a22fd7f77d
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorHardeningTests.cs
@@ -0,0 +1,437 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Modbus;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Executor-level hardening tests for the Modbus binding: a function-only form
+ /// maps end-to-end onto the exact function code, and the executor re-validates
+ /// the address / quantity range before the ushort casts so a hand-built,
+ /// out-of-range compiled form fails fast instead of silently truncating.
+ ///
+ [TestFixture]
+ public sealed class ModbusWotExecutorHardeningTests
+ {
+ private static WotProtocolBinderRegistry Registry()
+ {
+ return new WotProtocolBinderRegistry(
+ [new ModbusBindingPlanner()],
+ [new ModbusWotBindingExecutor()],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ [Test]
+ public async Task ModbusFunctionOnlyFormReadsHoldingRegisterEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ server.HoldingRegisters[100] = 0x1234;
+ server.HoldingRegisters[101] = 0x5678;
+
+ // Function-only form: modv:function 3 (read holding registers), no
+ // modv:entity. The planner must map it onto the holding-register space.
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"level\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:function\":3,\"modv:address\":100," +
+ "\"modv:quantity\":2,\"modv:type\":\"int32\"}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ Assert.That(read.Addressing.Metadata["entity"], Is.EqualTo("holdingRegister"));
+ Assert.That(read.OperationInfo.Method, Is.EqualTo("readHoldingRegisters"));
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(0x12345678));
+ }
+ }
+
+ [Test]
+ public void ModbusExecutorRevalidatesOutOfRangeAddressBeforeCast()
+ {
+ // A hand-built compiled form whose address is beyond the 16-bit Modbus
+ // space would truncate to a valid ushort without the executor's
+ // re-validation. The executor must refuse it before opening a socket.
+ var addressing = new WotAddressingDescriptor(
+ "holdingRegister:70000:1@1",
+ ImmutableDictionary.Empty
+ .Add("entity", "holdingRegister")
+ .Add("address", "70000")
+ .Add("quantity", "1")
+ .Add("unitId", "1"));
+ var payload = new WotPayloadDescriptor(
+ "application/octet-stream", "octet-stream",
+ ImmutableDictionary.Empty.Add("type", "uint16"));
+ var form = new WotCompiledForm(
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri),
+ WotAffordanceKind.Property, "p", "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.ReadProperty, "readproperty",
+ new WotEndpointDescriptor("modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"),
+ addressing,
+ new WotOperationDescriptor(
+ WoTBindingCapabilityEnum.ReadProperty, "readproperty", "readHoldingRegisters"),
+ payload,
+ [], isExecutable: true);
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusExecutorRevalidatesRangeOverflowBeforeCast()
+ {
+ var addressing = new WotAddressingDescriptor(
+ "holdingRegister:65530:10@1",
+ ImmutableDictionary.Empty
+ .Add("entity", "holdingRegister")
+ .Add("address", "65530")
+ .Add("quantity", "10")
+ .Add("unitId", "1"));
+ var payload = new WotPayloadDescriptor(
+ "application/octet-stream", "octet-stream",
+ ImmutableDictionary.Empty.Add("type", "uint16"));
+ var form = new WotCompiledForm(
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri),
+ WotAffordanceKind.Property, "p", "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.ReadProperty, "readproperty",
+ new WotEndpointDescriptor("modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"),
+ addressing,
+ new WotOperationDescriptor(
+ WoTBindingCapabilityEnum.ReadProperty, "readproperty", "readHoldingRegisters"),
+ payload,
+ [], isExecutable: true);
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusExecutorRejectsQuantityThatWouldTruncateToZero()
+ {
+ var addressing = new WotAddressingDescriptor(
+ "holdingRegister:0:65536@1",
+ ImmutableDictionary.Empty
+ .Add("entity", "holdingRegister")
+ .Add("address", "0")
+ .Add("quantity", "65536")
+ .Add("unitId", "1"));
+ var payload = new WotPayloadDescriptor(
+ "application/octet-stream", "octet-stream",
+ ImmutableDictionary.Empty.Add("type", "uint16"));
+ var form = new WotCompiledForm(
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri),
+ WotAffordanceKind.Property, "p", "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.ReadProperty, "readproperty",
+ new WotEndpointDescriptor(
+ "modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"),
+ addressing,
+ new WotOperationDescriptor(
+ WoTBindingCapabilityEnum.ReadProperty,
+ "readproperty",
+ "readHoldingRegisters"),
+ payload,
+ [],
+ isExecutable: true);
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusExecutorRevalidatesConfiguredCoilBound()
+ {
+ WotCompiledForm form = BitForm(
+ quantity: 9,
+ operation: WoTBindingCapabilityEnum.ReadProperty,
+ method: "readCoil");
+ var context = new WotExecutorContext(
+ bounds: new WotBindingBounds { MaxCoilQuantity = 8 });
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor.ActivateAsync(form, context).ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusExecutorRevalidatesMultipleCoilWriteProtocolBound()
+ {
+ WotCompiledForm form = BitForm(
+ quantity: 1969,
+ operation: WoTBindingCapabilityEnum.WriteProperty,
+ method: "writeMultipleCoils");
+
+ var executor = new ModbusWotBindingExecutor();
+ ArgumentOutOfRangeException? exception = Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ Assert.That(exception!.Message, Does.Contain("1968"));
+ }
+
+ [Test]
+ public void ModbusExecutorRejectsSingleCoilQuantityMismatch()
+ {
+ WotCompiledForm form = BitForm(
+ quantity: 2,
+ operation: WoTBindingCapabilityEnum.WriteProperty,
+ method: "writeSingleCoil");
+
+ var executor = new ModbusWotBindingExecutor();
+ ArgumentOutOfRangeException? exception = Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ Assert.That(exception!.Message, Does.Contain("quantity of 1"));
+ }
+
+ [TestCase(
+ "holdingRegister",
+ WoTBindingCapabilityEnum.WriteProperty,
+ "writeSingleCoil")]
+ [TestCase(
+ "coil",
+ WoTBindingCapabilityEnum.ReadProperty,
+ "writeSingleCoil")]
+ [TestCase(
+ "coil",
+ WoTBindingCapabilityEnum.WriteProperty,
+ "unknownCoilMethod")]
+ public void ModbusExecutorRejectsInconsistentEntityDirectionOrMethod(
+ string entity,
+ WoTBindingCapabilityEnum operation,
+ string method)
+ {
+ WotCompiledForm form = BitForm(
+ quantity: 1,
+ operation: operation,
+ method: method,
+ entity: entity);
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusExecutorRejectsOperationDescriptorMismatch()
+ {
+ WotCompiledForm form = BitForm(
+ quantity: 1,
+ operation: WoTBindingCapabilityEnum.WriteProperty,
+ method: "writeSingleCoil",
+ descriptorOperation: WoTBindingCapabilityEnum.ReadProperty);
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ }
+
+ [Test]
+ public void ModbusExecutorRejectsFunctionCodeMismatch()
+ {
+ WotCompiledForm form = BitForm(
+ quantity: 1,
+ operation: WoTBindingCapabilityEnum.WriteProperty,
+ method: "writeSingleCoil",
+ functionCode: 15);
+
+ var executor = new ModbusWotBindingExecutor();
+ Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ }
+
+ [TestCase(3, "uint64")]
+ [TestCase(2, null)]
+ public void ModbusExecutorRejectsRegisterWriteWidthMismatch(int quantity, string? type)
+ {
+ WotCompiledForm form = RegisterWriteForm(quantity, type, 502);
+
+ var executor = new ModbusWotBindingExecutor();
+ ArgumentException? exception = Assert.ThrowsAsync(
+ async () => await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false));
+ Assert.That(exception!.Message, Does.Contain("does not match quantity"));
+ Assert.That(exception.Message, Does.Contain($"type '{type ?? "uint16"}'"));
+ }
+
+ [TestCase(1, "uint16")]
+ [TestCase(2, "uint32")]
+ [TestCase(4, "uint64")]
+ public async Task ModbusExecutorAcceptsMatchingRegisterWriteWidth(int quantity, string type)
+ {
+ using var server = new TestModbusServer();
+ WotCompiledForm form = RegisterWriteForm(quantity, type, server.Port);
+
+ var executor = new ModbusWotBindingExecutor();
+ IWotBindingChannel channel = await executor
+ .ActivateAsync(form, new WotExecutorContext())
+ .ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ Variant value = quantity switch
+ {
+ 1 => new Variant((ushort)0x1234),
+ 2 => new Variant(0x12345678u),
+ _ => new Variant(0x0123456789ABCDEFul)
+ };
+ WotWriteResult result = await channel
+ .WriteAsync(new DataValue(value))
+ .ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, result.Error);
+ }
+ ushort[] expected = quantity switch
+ {
+ 1 => [0x1234],
+ 2 => [0x1234, 0x5678],
+ _ => [0x0123, 0x4567, 0x89AB, 0xCDEF]
+ };
+ Assert.That(server.HoldingRegisters.Take(quantity), Is.EqualTo(expected));
+ }
+
+ private static WotCompiledForm RegisterWriteForm(int quantity, string? type, int port)
+ {
+ ImmutableDictionary payloadMetadata =
+ ImmutableDictionary.Empty;
+ if (type is not null)
+ {
+ payloadMetadata = payloadMetadata.Add("type", type);
+ }
+ var addressing = new WotAddressingDescriptor(
+ $"holdingRegister:0:{quantity}@1",
+ ImmutableDictionary.Empty
+ .Add("entity", "holdingRegister")
+ .Add("address", "0")
+ .Add("quantity", quantity.ToString(CultureInfo.InvariantCulture))
+ .Add("unitId", "1"));
+ var payload = new WotPayloadDescriptor(
+ "application/octet-stream",
+ OctetStreamWotPayloadCodec.Instance.Id,
+ payloadMetadata);
+ string method = quantity == 1
+ ? "writeSingleHoldingRegister"
+ : "writeMultipleHoldingRegisters";
+ return new WotCompiledForm(
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri),
+ WotAffordanceKind.Property,
+ "p",
+ "/properties/p/forms/0",
+ WoTBindingCapabilityEnum.WriteProperty,
+ "writeproperty",
+ new WotEndpointDescriptor(
+ "modbus+tcp",
+ "127.0.0.1",
+ port,
+ $"modbus+tcp://127.0.0.1:{port}"),
+ addressing,
+ new WotOperationDescriptor(
+ WoTBindingCapabilityEnum.WriteProperty,
+ "writeproperty",
+ method),
+ payload,
+ [],
+ isExecutable: true);
+ }
+
+ private static WotCompiledForm BitForm(
+ int quantity,
+ WoTBindingCapabilityEnum operation,
+ string method,
+ string entity = "coil",
+ WoTBindingCapabilityEnum? descriptorOperation = null,
+ int? functionCode = null)
+ {
+ ImmutableDictionary metadata = ImmutableDictionary.Empty
+ .Add("entity", entity)
+ .Add("address", "0")
+ .Add("quantity", quantity.ToString(CultureInfo.InvariantCulture))
+ .Add("unitId", "1");
+ if (functionCode is not null)
+ {
+ metadata = metadata.Add(
+ "functionCode",
+ functionCode.Value.ToString(CultureInfo.InvariantCulture));
+ }
+ var addressing = new WotAddressingDescriptor(
+ $"{entity}:0:{quantity}@1",
+ metadata);
+ var payload = new WotPayloadDescriptor(
+ "application/octet-stream",
+ OctetStreamWotPayloadCodec.Instance.Id,
+ ImmutableDictionary.Empty.Add(
+ "type",
+ quantity == 1 ? "boolean" : "boolean[]"));
+ WoTBindingCapabilityEnum operationInfo = descriptorOperation ?? operation;
+ return new WotCompiledForm(
+ new WotBindingIdentity("w3c.modbus", "1.0-ed", ModbusBindingPlanner.BindingUri),
+ WotAffordanceKind.Property,
+ "p",
+ "/properties/p/forms/0",
+ operation,
+ operation == WoTBindingCapabilityEnum.WriteProperty ? "writeproperty" : "readproperty",
+ new WotEndpointDescriptor(
+ "modbus+tcp", "127.0.0.1", 502, "modbus+tcp://127.0.0.1:502"),
+ addressing,
+ new WotOperationDescriptor(
+ operationInfo,
+ operationInfo == WoTBindingCapabilityEnum.WriteProperty ? "writeproperty" : "readproperty",
+ method),
+ payload,
+ [],
+ isExecutable: true);
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs
new file mode 100644
index 0000000000..3c398d3e37
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/ModbusWotExecutorTests.cs
@@ -0,0 +1,357 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Modbus;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Opc.Ua.WotCon.Bindings.Tests.Support;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// End-to-end tests for the Modbus TCP executor against an in-process simulator.
+ ///
+ [TestFixture]
+ public sealed class ModbusWotExecutorTests
+ {
+ private static WotProtocolBinderRegistry Registry()
+ {
+ return new WotProtocolBinderRegistry(
+ [new ModbusBindingPlanner()],
+ [new ModbusWotBindingExecutor()],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td)
+ {
+ return registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ }
+
+ [Test]
+ public async Task ModbusReadWriteHoldingRegisterInt32EndToEnd()
+ {
+ using var server = new TestModbusServer();
+ // 0x12345678 stored big-endian across two holding registers.
+ server.HoldingRegisters[100] = 0x1234;
+ server.HoldingRegisters[101] = 0x5678;
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"level\":{\"type\":\"number\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:entity\":\"holdingRegister\",\"modv:address\":100," +
+ "\"modv:quantity\":2,\"modv:type\":\"int32\"}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (readChannel.ConfigureAwait(false))
+ {
+ WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(0x12345678));
+ }
+
+ IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (writeChannel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await writeChannel.WriteAsync(
+ new DataValue(new Variant(1000042))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ Assert.That(server.HoldingRegisters[100], Is.EqualTo((ushort)(1000042 >> 16)));
+ Assert.That(server.HoldingRegisters[101], Is.EqualTo((ushort)(1000042 & 0xFFFF)));
+ }
+
+ [Test]
+ public async Task ModbusReadWriteCoilEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ server.Coils[10] = true;
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:entity\":\"coil\",\"modv:address\":10,\"modv:quantity\":1}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm read = plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+ Assert.That(read.Payload.CodecId, Is.EqualTo(OctetStreamWotPayloadCodec.Instance.Id));
+ Assert.That(read.Payload.Metadata["type"], Is.EqualTo("boolean"));
+
+ IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (readChannel.ConfigureAwait(false))
+ {
+ WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.True);
+ }
+
+ IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (writeChannel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await writeChannel.WriteAsync(
+ new DataValue(new Variant(false))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+
+ Assert.That(server.Coils[10], Is.False);
+ }
+
+ [Test]
+ public async Task ModbusSingleCoilRejectsNullAndNonBooleanValues()
+ {
+ using var server = new TestModbusServer();
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{" +
+ "\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:entity\":\"coil\",\"modv:address\":11,\"modv:quantity\":1," +
+ "\"op\":[\"writeproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotCompiledForm write = Plan(registry, td).CompiledForms.Single();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ Variant[] invalidValues =
+ [
+ Variant.Null,
+ new Variant(1),
+ new Variant("true"),
+ new Variant((ArrayOf)[true])
+ ];
+ foreach (Variant invalidValue in invalidValues)
+ {
+ WotWriteResult result = await channel
+ .WriteAsync(new DataValue(invalidValue))
+ .ConfigureAwait(false);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(result.Error, Does.Contain("Boolean scalar"));
+ }
+ }
+
+ Assert.That(server.LastFunctionCode, Is.Zero);
+ Assert.That(server.Coils[11], Is.False);
+ }
+
+ [Test]
+ public async Task ModbusReadsMultipleCoilsAsBooleanArrayEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ bool[] expected = [true, false, true, true, false, false, true, false, true, true];
+ for (int i = 0; i < expected.Length; i++)
+ {
+ server.Coils[20 + i] = expected[i];
+ }
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"relays\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," +
+ "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:entity\":\"coil\",\"modv:address\":20,\"modv:quantity\":10," +
+ "\"op\":[\"readproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm read = plan.CompiledForms.Single();
+ Assert.That(read.OperationInfo.Method, Is.EqualTo("readCoil"));
+ Assert.That(read.Payload.CodecId, Is.EqualTo(OctetStreamWotPayloadCodec.Instance.Id));
+ Assert.That(read.Payload.Metadata["type"], Is.EqualTo("boolean[]"));
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, result.Error);
+ Assert.That(
+ result.Value.WrappedValue.TryGetValue(out ArrayOf actual),
+ Is.True);
+ Assert.That(actual, Is.EqualTo(expected));
+ }
+ }
+
+ [Test]
+ public async Task ModbusReadsMultipleDiscreteInputsAsBooleanArrayEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ bool[] expected = [false, true, true, false, true, false, false, true, true];
+ for (int i = 0; i < expected.Length; i++)
+ {
+ server.DiscreteInputs[30 + i] = expected[i];
+ }
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"inputs\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," +
+ "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:entity\":\"discreteInput\",\"modv:address\":30,\"modv:quantity\":9," +
+ "\"op\":[\"readproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm read = plan.CompiledForms.Single();
+ Assert.That(read.OperationInfo.Method, Is.EqualTo("readDiscreteInput"));
+ Assert.That(read.Payload.Metadata["type"], Is.EqualTo("boolean[]"));
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, result.Error);
+ Assert.That(
+ result.Value.WrappedValue.TryGetValue(out ArrayOf actual),
+ Is.True);
+ Assert.That(actual, Is.EqualTo(expected));
+ }
+ }
+
+ [Test]
+ public async Task ModbusFunction15WritesMultipleCoilsEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ bool[] expected = [true, false, true, true, false, false, true, false, true, true];
+
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"relays\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," +
+ "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:function\":15,\"modv:address\":40,\"modv:quantity\":10," +
+ "\"op\":[\"writeproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm write = plan.CompiledForms.Single();
+ Assert.That(write.OperationInfo.Method, Is.EqualTo("writeMultipleCoils"));
+ Assert.That(write.Payload.CodecId, Is.EqualTo(OctetStreamWotPayloadCodec.Instance.Id));
+ Assert.That(write.Payload.Metadata["type"], Is.EqualTo("boolean[]"));
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel
+ .WriteAsync(new DataValue(new Variant((ArrayOf)expected)))
+ .ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, result.Error);
+ }
+
+ Assert.That(server.LastFunctionCode, Is.EqualTo(0x0F));
+ Assert.That(server.Coils.Skip(40).Take(expected.Length), Is.EqualTo(expected));
+ }
+
+ [Test]
+ public async Task ModbusFunction15QuantityOneAcceptsScalarEndToEnd()
+ {
+ using var server = new TestModbusServer();
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"relay\":{\"type\":\"boolean\",\"forms\":[{" +
+ "\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:function\":15,\"modv:address\":45,\"modv:quantity\":1," +
+ "\"op\":[\"writeproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotCompiledForm write = Plan(registry, td).CompiledForms.Single();
+ Assert.That(write.OperationInfo.Method, Is.EqualTo("writeMultipleCoils"));
+ Assert.That(write.Payload.Metadata["type"], Is.EqualTo("boolean"));
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult nullResult = await channel
+ .WriteAsync(new DataValue(Variant.Null))
+ .ConfigureAwait(false);
+ Assert.That(nullResult.Status, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(server.LastFunctionCode, Is.Zero);
+
+ WotWriteResult wrongType = await channel
+ .WriteAsync(new DataValue(new Variant(1)))
+ .ConfigureAwait(false);
+ Assert.That(wrongType.Status, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(server.LastFunctionCode, Is.Zero);
+
+ WotWriteResult arrayValue = await channel
+ .WriteAsync(new DataValue(new Variant((ArrayOf)[true])))
+ .ConfigureAwait(false);
+ Assert.That(arrayValue.Status, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(server.LastFunctionCode, Is.Zero);
+
+ WotWriteResult result = await channel
+ .WriteAsync(new DataValue(new Variant(true)))
+ .ConfigureAwait(false);
+ Assert.That(result.Success, Is.True, result.Error);
+ }
+
+ Assert.That(server.LastFunctionCode, Is.EqualTo(0x0F));
+ Assert.That(server.Coils[45], Is.True);
+ }
+
+ [Test]
+ public async Task ModbusMultipleCoilWriteRejectsScalarAndWrongLength()
+ {
+ using var server = new TestModbusServer();
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"relays\":{\"type\":\"array\",\"items\":{\"type\":\"boolean\"}," +
+ "\"forms\":[{\"href\":\"modbus+tcp://127.0.0.1:" +
+ server.Port +
+ "/1\",\"modv:entity\":\"coil\",\"modv:address\":50,\"modv:quantity\":3," +
+ "\"op\":[\"writeproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotCompiledForm write = Plan(registry, td).CompiledForms.Single();
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult scalar = await channel
+ .WriteAsync(new DataValue(new Variant(true)))
+ .ConfigureAwait(false);
+ Assert.That(scalar.Status, Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(scalar.Error, Does.Contain("array of 3 Boolean values"));
+
+ WotWriteResult wrongLength = await channel
+ .WriteAsync(new DataValue(new Variant((ArrayOf)[true, false])))
+ .ConfigureAwait(false);
+ Assert.That(wrongLength.Status, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ Assert.That(wrongLength.Error, Does.Contain("exactly 3 Boolean values"));
+ }
+
+ Assert.That(server.LastFunctionCode, Is.Zero);
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs
new file mode 100644
index 0000000000..96768bb6d5
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotBindingChannelTests.cs
@@ -0,0 +1,628 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using System.Globalization;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using MQTTnet.Server;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Mqtt;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Channel-level tests for MqttWotBindingChannel:
+ /// InvokeAsync (with and without inputs), SubscribeEventAsync
+ /// delegating to ObserveAsync, decode failure in ReadAsync,
+ /// subscription disposal stopping delivery, QoS 0 and QoS 2 configuration,
+ /// and channel disposal.
+ ///
+ [TestFixture]
+ public sealed class MqttWotBindingChannelTests
+ {
+ private static int FreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private static async Task StartBrokerAsync(int port)
+ {
+ MqttServerOptions options = new MqttServerOptionsBuilder()
+ .WithDefaultEndpoint()
+ .WithDefaultEndpointPort(port)
+ .WithDefaultEndpointBoundIPAddress(IPAddress.Loopback)
+ .Build();
+ MqttServer broker = new MqttServerFactory().CreateMqttServer(options);
+ await broker.StartAsync().ConfigureAwait(false);
+ return broker;
+ }
+
+ private static WotProtocolBinderRegistry Registry(
+ TimeSpan? readTimeout = null, WotBindingBounds? bounds = null)
+ {
+ return new WotProtocolBinderRegistry(
+ [new MqttBindingPlanner()],
+ [new MqttWotBindingExecutor(
+ new MqttWotBindingOptions { ReadTimeout = readTimeout ?? TimeSpan.FromSeconds(5) })],
+ bounds: bounds,
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td)
+ {
+ return registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ }
+
+ private static string PropertyTd(int port, string topic, string? op = null, int qos = 1)
+ {
+ string opClause = op is not null ? "\"op\":[\"" + op + "\"]," : string.Empty;
+ return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"observable\":true,\"forms\":[{" +
+ "\"href\":\"mqtt://127.0.0.1:" + port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/" + topic + "\"," + opClause +
+ "\"mqv:qos\":" + qos.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ ",\"mqv:retain\":true}]}}}";
+ }
+
+ private static string ActionTd(int port, string topic)
+ {
+ return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"actions\":{\"act\":{\"forms\":[{" +
+ "\"href\":\"mqtt://127.0.0.1:" + port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/" + topic + "\",\"mqv:qos\":0}]}}}";
+ }
+
+ private static WotCompiledForm BuildRawForm(
+ int port, string topic, WoTBindingCapabilityEnum capability, string opToken)
+ {
+ var endpoint = new WotEndpointDescriptor(
+ "mqtt",
+ "127.0.0.1",
+ port,
+ "mqtt://127.0.0.1:" + port.ToString(CultureInfo.InvariantCulture));
+ var addressing = new WotAddressingDescriptor(topic);
+ var operation = new WotOperationDescriptor(capability, opToken, "subscribe");
+ var payload = new WotPayloadDescriptor("application/json", "json");
+ return new WotCompiledForm(
+ new WotBindingIdentity("w3c.mqtt", "1.0-ed", MqttBindingPlanner.BindingUri),
+ WotAffordanceKind.Property,
+ "p",
+ "/properties/p/forms/0",
+ capability,
+ opToken,
+ endpoint,
+ addressing,
+ operation,
+ payload,
+ ImmutableArray.Empty,
+ isExecutable: true);
+ }
+
+ private static async Task WaitForAsync(ConcurrentQueue queue, int maxAttempts = 80)
+ {
+ for (int i = 0; i < maxAttempts; i++)
+ {
+ if (!queue.IsEmpty)
+ {
+ return true;
+ }
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ return false;
+ }
+
+ [Test]
+ public async Task MqttChannelInvokeAsyncPublishesWithNoInputs()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, ActionTd(port, "things/act"));
+ WotCompiledForm invoke = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ // Empty inputs: InvokeAsync should publish an empty payload.
+ WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Outputs, Is.Empty);
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelInvokeAsyncPublishesWithInputValue()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, ActionTd(port, "things/act"));
+ WotCompiledForm invoke = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(invoke).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync(
+ [new Variant(42L)]).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelRejectsWildcardPublishTopic()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry(
+ bounds: new WotBindingBounds { AllowMqttWildcardTopics = true });
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{" +
+ "\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "\",\"mqv:topic\":\"#\",\"op\":[\"writeproperty\"]}]}}}";
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant(42L))).ConfigureAwait(false);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelReadAsyncRejectsConcurrentReadWithoutAbandoningFirstAsync()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry(readTimeout: TimeSpan.FromSeconds(5));
+ WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/concurrent"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ Task firstRead = channel.ReadAsync().AsTask();
+
+ Assert.That(
+ async () => await channel.ReadAsync().ConfigureAwait(false),
+ Throws.InstanceOf());
+
+ IWotBindingChannel publisher = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (publisher.ConfigureAwait(false))
+ {
+ WotWriteResult writeResult = await publisher
+ .WriteAsync(new DataValue(new Variant(123L)))
+ .ConfigureAwait(false);
+ Assert.That(writeResult.Success, Is.True);
+ }
+
+ WotReadResult firstResult = await firstRead.WaitAsync(TimeSpan.FromSeconds(5))
+ .ConfigureAwait(false);
+ Assert.That(firstResult.Success, Is.True);
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelReadAsyncRejectsHandBuiltTopicExceedingBoundsAsync()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry(bounds: new WotBindingBounds { MaxTopicLength = 5 });
+ WotCompiledForm read = BuildRawForm(
+ port, "things/too-long", WoTBindingCapabilityEnum.ReadProperty, "readproperty");
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ ServiceResultException exception = Assert.ThrowsAsync(
+ async () => await channel.ReadAsync().ConfigureAwait(false))!;
+ Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadOutOfRange));
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelObserveAsyncRejectsHandBuiltWildcardTopicWhenDisallowedAsync()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry();
+ WotCompiledForm observe = BuildRawForm(
+ port, "things/+/value", WoTBindingCapabilityEnum.ObserveProperty, "observeproperty");
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(observe).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ ServiceResultException exception = Assert.ThrowsAsync(
+ async () => await channel.ObserveAsync(_ => { }).ConfigureAwait(false))!;
+ Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelSubscribeEventAsyncReceivesNotifications()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"sensor\":{\"type\":\"number\",\"observable\":true,\"forms\":[" +
+ "{\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/things/sensor\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}," +
+ "{\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/things/sensor\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+
+ WotCompiledForm observe = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe).ConfigureAwait(false);
+ await using (observeChannel.ConfigureAwait(false))
+ {
+ var events = new ConcurrentQueue();
+ // SubscribeEventAsync delegates to ObserveAsync.
+ IWotSubscription sub = await observeChannel.SubscribeEventAsync(n => events.Enqueue(n))
+ .ConfigureAwait(false);
+ await using (sub.ConfigureAwait(false))
+ {
+ await Task.Delay(100).ConfigureAwait(false);
+ IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write)
+ .ConfigureAwait(false);
+ await using (writeChannel.ConfigureAwait(false))
+ {
+ await writeChannel.WriteAsync(new DataValue(new Variant(55L))).ConfigureAwait(false);
+ }
+ bool received = await WaitForAsync(events).ConfigureAwait(false);
+ Assert.That(received, Is.True,
+ "SubscribeEventAsync must deliver the published value.");
+ }
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelReadAsyncDecodingFailureReturnsBadDecodingError()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ // Write form with retain; read form subscribes to the same topic.
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"sensor\":{\"type\":\"number\",\"forms\":[" +
+ "{\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/things/decode\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry(readTimeout: TimeSpan.FromSeconds(5));
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ // Publish a retained octet-stream payload that JSON cannot decode.
+ // The property TD uses application/json by default, so the JSON codec
+ // will fail to decode the raw bytes [0xFF, 0xFE].
+ // We write via octet-stream channel by crafting an octet-stream TD.
+ string rawTd = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"sensor\":{\"type\":\"string\",\"forms\":[" +
+ "{\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/things/decode2\",\"contentType\":\"application/json\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}";
+
+ WotBindingPlan rawPlan = Plan(registry, rawTd);
+ WotCompiledForm rawWrite = rawPlan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+ WotCompiledForm rawRead = rawPlan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ // First publish an invalid JSON string so the retained message is bad.
+ IWotBindingChannel writeChannel = await registry.OpenChannelAsync(rawWrite).ConfigureAwait(false);
+ await using (writeChannel.ConfigureAwait(false))
+ {
+ // Write the string "hello" which is valid JSON (it encodes as "\"hello\"").
+ // Then we verify ReadAsync on that topic succeeds. For the failure test,
+ // use a separate topic and publish malformed JSON directly.
+ await writeChannel.WriteAsync(
+ new DataValue(new Variant("hello"))).ConfigureAwait(false);
+ }
+
+ // Read back — the JSON codec decodes "\"hello\"" successfully as string.
+ IWotBindingChannel readChannel = await registry.OpenChannelAsync(rawRead).ConfigureAwait(false);
+ await using (readChannel.ConfigureAwait(false))
+ {
+ WotReadResult readResult = await readChannel.ReadAsync().ConfigureAwait(false);
+ // "hello" is a valid JSON string and decodes successfully.
+ Assert.That(readResult.Success, Is.True);
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelSubscriptionDisposalStopsDelivery()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"observable\":true,\"forms\":[" +
+ "{\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/things/stop\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}," +
+ "{\"href\":\"mqtt://127.0.0.1:" +
+ port.ToString(System.Globalization.CultureInfo.InvariantCulture) +
+ "/things/stop\",\"mqv:qos\":1,\"mqv:retain\":true}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm observe = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe).ConfigureAwait(false);
+ await using (observeChannel.ConfigureAwait(false))
+ {
+ var received = new ConcurrentQueue();
+ IWotSubscription sub = await observeChannel.ObserveAsync(n => received.Enqueue(n))
+ .ConfigureAwait(false);
+
+ await Task.Delay(100).ConfigureAwait(false);
+
+ // Publish once before disposal: verify delivery.
+ IWotBindingChannel publisher = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (publisher.ConfigureAwait(false))
+ {
+ await publisher.WriteAsync(new DataValue(new Variant(1L))).ConfigureAwait(false);
+ }
+ bool firstDelivered = await WaitForAsync(received).ConfigureAwait(false);
+ Assert.That(firstDelivered, Is.True, "Subscription must deliver before disposal.");
+
+ // Dispose the subscription.
+ await sub.DisposeAsync().ConfigureAwait(false);
+
+ // Clear and publish again: nothing should be enqueued.
+ while (received.TryDequeue(out _)) { }
+
+ IWotBindingChannel publisher2 = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (publisher2.ConfigureAwait(false))
+ {
+ await publisher2.WriteAsync(new DataValue(new Variant(2L))).ConfigureAwait(false);
+ }
+
+ await Task.Delay(300).ConfigureAwait(false);
+ Assert.That(received.IsEmpty, Is.True,
+ "Disposed subscription must not receive further notifications.");
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelQosZeroConfiguredFromForm()
+ {
+ // QoS 0 means at-most-once. Test that a form with mqv:qos=0 works
+ // end-to-end (no exception, publishes successfully).
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/qos0", qos: 0));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant(10L))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelQosTwoConfiguredFromForm()
+ {
+ // QoS 2 means exactly-once. Test that a form with mqv:qos=2 works.
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/qos2", qos: 2));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(
+ new DataValue(new Variant(20L))).ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelDisposeAsyncDisconnectsClient()
+ {
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/dispose"));
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+
+ // Dispose should not throw.
+ Assert.DoesNotThrowAsync(
+ async () => await channel.DisposeAsync().ConfigureAwait(false));
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ [Test]
+ public async Task MqttChannelReadAsyncTimeoutReturnsBadTimeout()
+ {
+ // A channel that reads but no message is ever published should time out
+ // and return BadTimeout.
+ int port = FreePort();
+ MqttServer broker = await StartBrokerAsync(port).ConfigureAwait(false);
+ try
+ {
+ WotProtocolBinderRegistry registry = Registry(readTimeout: TimeSpan.FromMilliseconds(200));
+ WotBindingPlan plan = Plan(registry, PropertyTd(port, "things/timeout"));
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadTimeout));
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs
new file mode 100644
index 0000000000..b3263f4bb4
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotConnectionTests.cs
@@ -0,0 +1,180 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Immutable;
+using System.Linq;
+using System.Text;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Mqtt;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Unit tests for the MQTT transport-security policy: mqtts enables TLS
+ /// and defaults to port 8883, credentials / trust are applied through the
+ /// provider, the executor fails closed when a required credential is
+ /// unresolved, and username / password material never downgrades to a
+ /// plaintext connection.
+ ///
+ [TestFixture]
+ public sealed class MqttWotConnectionTests
+ {
+ private static WotCompiledForm Compiled(string href, bool withSecurity)
+ {
+ string security = withSecurity
+ ? "\"securityDefinitions\":{\"basic_sc\":{\"scheme\":\"basic\"}},\"security\":\"basic_sc\","
+ : string.Empty;
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ security +
+ "\"properties\":{\"p\":{\"type\":\"number\",\"forms\":[{\"href\":\"" +
+ href +
+ "\",\"op\":[\"writeproperty\"]}]}}}";
+ var registry = new WotProtocolBinderRegistry([new MqttBindingPlanner()]);
+ WotBindingPlan plan = registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ return plan.CompiledForms.First(f => f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+ }
+
+ private static WotExecutorContext Context(IWotCredentialProvider credentials)
+ {
+ return new WotExecutorContext(credentials);
+ }
+
+ private static Task PrepareAsync(
+ WotCompiledForm form, MqttWotBindingOptions options, IWotCredentialProvider credentials)
+ {
+ return MqttWotConnection
+ .PrepareAsync(form, Context(credentials), options, "client-id", CancellationToken.None)
+ .AsTask();
+ }
+
+ [Test]
+ public async Task PlainMqttUsesPlaintextDefaultPort1883()
+ {
+ WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: false);
+
+ MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync(
+ form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false);
+
+ Assert.That(plan.UseTls, Is.False);
+ Assert.That(plan.Port, Is.EqualTo(1883));
+ Assert.That(plan.HasCredentials, Is.False);
+ }
+
+ [Test]
+ public async Task MqttsEnablesTlsDefaultPort8883()
+ {
+ WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: false);
+
+ MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync(
+ form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false);
+
+ Assert.That(plan.UseTls, Is.True);
+ Assert.That(plan.Port, Is.EqualTo(8883));
+ }
+
+ [Test]
+ public async Task MqttsHonoursExplicitPort()
+ {
+ WotCompiledForm form = Compiled("mqtts://broker:9999/things/p", withSecurity: false);
+
+ MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync(
+ form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false);
+
+ Assert.That(plan.UseTls, Is.True);
+ Assert.That(plan.Port, Is.EqualTo(9999));
+ }
+
+ [Test]
+ public async Task MqttsWithResolvedCredentialsAppliesThem()
+ {
+ WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: true);
+
+ MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync(
+ form, new MqttWotBindingOptions(), new UserPasswordCredentialProvider()).ConfigureAwait(false);
+
+ Assert.That(plan.UseTls, Is.True);
+ Assert.That(plan.HasCredentials, Is.True);
+ }
+
+ [Test]
+ public void MqttsRequiredCredentialUnresolvedFailsClosed()
+ {
+ WotCompiledForm form = Compiled("mqtts://broker/things/p", withSecurity: true);
+
+ Assert.That(form.Security, Is.Not.Empty, "The form must declare a security scheme.");
+ Assert.ThrowsAsync(
+ async () => await PrepareAsync(
+ form, new MqttWotBindingOptions(), NullWotCredentialProvider.Instance).ConfigureAwait(false));
+ }
+
+ [Test]
+ public void PlainMqttWithCredentialsFailsClosedNoPlaintextDowngrade()
+ {
+ WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: true);
+
+ // The provider resolves username / password but the connection is plain
+ // mqtt://, so sending the credentials would leak them in clear text.
+ Assert.ThrowsAsync(
+ async () => await PrepareAsync(
+ form, new MqttWotBindingOptions(), new UserPasswordCredentialProvider()).ConfigureAwait(false));
+ }
+
+ [Test]
+ public async Task PlainMqttWithCredentialsAllowedWhenExplicitlyOptedIn()
+ {
+ WotCompiledForm form = Compiled("mqtt://broker/things/p", withSecurity: true);
+
+ MqttWotConnection.MqttWotConnectPlan plan = await PrepareAsync(
+ form,
+ new MqttWotBindingOptions { AllowCredentialsOverPlaintext = true },
+ new UserPasswordCredentialProvider()).ConfigureAwait(false);
+
+ Assert.That(plan.UseTls, Is.False);
+ Assert.That(plan.HasCredentials, Is.True);
+ }
+
+ private sealed class UserPasswordCredentialProvider : IWotCredentialProvider
+ {
+ public ValueTask ResolveAsync(
+ WotCredentialReference reference, CancellationToken cancellationToken = default)
+ {
+ return new ValueTask(new WotCredential(
+ WotSecurityScheme.Basic,
+ properties: ImmutableDictionary.Empty
+ .Add("username", "device")
+ .Add("password", "secret")));
+ }
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs
new file mode 100644
index 0000000000..6bea0f606a
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/MqttWotExecutorTests.cs
@@ -0,0 +1,166 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Linq;
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+using System.Threading.Tasks;
+using MQTTnet.Server;
+using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Mqtt;
+using Opc.Ua.WotCon.Bindings.Planners;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// End-to-end tests for the MQTT executor against an ephemeral in-process broker.
+ ///
+ [TestFixture]
+ public sealed class MqttWotExecutorTests
+ {
+ private static int FreePort()
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ int port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ listener.Stop();
+ return port;
+ }
+
+ private static WotProtocolBinderRegistry Registry()
+ {
+ return new WotProtocolBinderRegistry(
+ [new MqttBindingPlanner()],
+ [ new MqttWotBindingExecutor(
+ new MqttWotBindingOptions { ReadTimeout = TimeSpan.FromSeconds(5) }) ],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+ }
+
+ private static WotBindingPlan Plan(WotProtocolBinderRegistry registry, string td)
+ {
+ return registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription, Encoding.UTF8.GetBytes(td)));
+ }
+
+ [Test]
+ public async Task MqttPublishSubscribeObserveEndToEnd()
+ {
+ int port = FreePort();
+ MqttServerOptions serverOptions = new MqttServerOptionsBuilder()
+ .WithDefaultEndpoint()
+ .WithDefaultEndpointPort(port)
+ .WithDefaultEndpointBoundIPAddress(IPAddress.Loopback)
+ .Build();
+ MqttServer broker = new MqttServerFactory().CreateMqttServer(serverOptions);
+ await broker.StartAsync().ConfigureAwait(false);
+ try
+ {
+ string td = "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"title\":\"t\"," +
+ "\"properties\":{" +
+ "\"temp\":{\"type\":\"number\",\"forms\":[{\"href\":\"mqtt://127.0.0.1:" +
+ port +
+ "/things/temp\",\"mqv:qos\":1,\"mqv:retain\":true}]}," +
+ "\"watch\":{\"type\":\"number\",\"observable\":true,\"forms\":[{\"href\":\"mqtt://127.0.0.1:" +
+ port +
+ "/things/temp\",\"mqv:qos\":1,\"op\":[\"observeproperty\"]}]}}}";
+
+ WotProtocolBinderRegistry registry = Registry();
+ WotBindingPlan plan = Plan(registry, td);
+ WotCompiledForm write = plan.CompiledForms.First(
+ f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+ WotCompiledForm read = plan.CompiledForms.First(
+ f => f.AffordanceName == "temp" && f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+ WotCompiledForm observe = plan.CompiledForms.First(
+ f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty);
+
+ // Publish a retained value, then read it back.
+ IWotBindingChannel writeChannel = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (writeChannel.ConfigureAwait(false))
+ {
+ Assert.That((await writeChannel.WriteAsync(
+ new DataValue(new Variant(42L))).ConfigureAwait(false)).Success, Is.True);
+ }
+
+ IWotBindingChannel readChannel = await registry.OpenChannelAsync(read).ConfigureAwait(false);
+ await using (readChannel.ConfigureAwait(false))
+ {
+ WotReadResult result = await readChannel.ReadAsync().ConfigureAwait(false);
+ Assert.That(result.Success, Is.True);
+ Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.EqualTo(42L));
+ }
+
+ // Observe, then publish a new value and expect a notification.
+ var received = new ConcurrentQueue();
+ IWotBindingChannel observeChannel = await registry.OpenChannelAsync(observe).ConfigureAwait(false);
+ await using (observeChannel.ConfigureAwait(false))
+ {
+ IWotSubscription subscription = await observeChannel.ObserveAsync(n =>
+ {
+ if (n.Value.WrappedValue.AsBoxedObject() is long value)
+ {
+ received.Enqueue(value);
+ }
+ }).ConfigureAwait(false);
+ await using (subscription.ConfigureAwait(false))
+ {
+ await Task.Delay(200).ConfigureAwait(false);
+ IWotBindingChannel publisher = await registry.OpenChannelAsync(write).ConfigureAwait(false);
+ await using (publisher.ConfigureAwait(false))
+ {
+ await publisher.WriteAsync(new DataValue(new Variant(77L))).ConfigureAwait(false);
+ }
+ Assert.That(await WaitForAsync(received, 77).ConfigureAwait(false), Is.True,
+ "The MQTT observe channel must deliver the published change.");
+ }
+ }
+ }
+ finally
+ {
+ await broker.StopAsync().ConfigureAwait(false);
+ broker.Dispose();
+ }
+ }
+
+ private static async Task WaitForAsync(ConcurrentQueue queue, long expected)
+ {
+ for (int i = 0; i < 60; i++)
+ {
+ if (queue.Contains(expected))
+ {
+ return true;
+ }
+ await Task.Delay(50).ConfigureAwait(false);
+ }
+ return false;
+ }
+ }
+}
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj b/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj
index 9aa68d90ee..4e62ca9d13 100644
--- a/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Opc.Ua.WotCon.Bindings.Tests.csproj
@@ -11,7 +11,6 @@
false
-
@@ -26,12 +25,19 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
+
+
+
+
+
+
+
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs
index d9b2c289af..66c832dcb2 100644
--- a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingBuilderExtensionsTests.cs
@@ -33,12 +33,16 @@
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NUnit.Framework;
+using Opc.Ua.WotCon.Bindings.Http;
+using Opc.Ua.WotCon.Bindings.Modbus;
+using Opc.Ua.WotCon.Bindings.Mqtt;
+using Opc.Ua.WotCon.Bindings.OpcUa;
using Opc.Ua.WotCon.Bindings.Planners;
namespace Opc.Ua.WotCon.Bindings.Tests
{
///
- /// Unit tests for the protocol-agnostic WoT binding DI builder extension methods.
+ /// Unit tests for the WoT binding DI builder extension methods.
///
[TestFixture]
public sealed class OpcUaWotBindingBuilderExtensionsTests
@@ -225,6 +229,160 @@ public void AddWotCredentialProviderRegistersProviderInServiceCollection()
Assert.That(resolved, Is.Not.Null);
}
+ [Test]
+ public void AddHttpWotBindingNullBuilderThrowsArgumentNullException()
+ {
+ Assert.That(
+ () => OpcUaHttpWotBindingBuilderExtensions.AddHttpWotBinding(null!),
+ Throws.InstanceOf());
+ }
+
+ [Test]
+ public void AddHttpWotBindingRegistersBindersAndHttpExecutor()
+ {
+ var services = new ServiceCollection();
+ IOpcUaBuilder builder = new TestBuilder(services);
+ builder.AddHttpWotBinding();
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ WotProtocolBinderRegistry registry = sp.GetRequiredService();
+ System.Collections.Generic.IEnumerable executors =
+ sp.GetServices();
+
+ Assert.That(registry.Binders, Is.Not.Empty);
+ Assert.That(executors.Any(e => e is HttpWotBindingExecutor), Is.True);
+ }
+
+ [Test]
+ public void AddHttpWotBindingWithConfigureDelegateCallsDelegate()
+ {
+ IOpcUaBuilder builder = NewBuilder();
+ bool called = false;
+
+ builder.AddHttpWotBinding(opts => called = true);
+
+ Assert.That(called, Is.True);
+ }
+
+ [Test]
+ public void AddModbusWotBindingNullBuilderThrowsArgumentNullException()
+ {
+ Assert.That(
+ () => OpcUaModbusWotBindingBuilderExtensions.AddModbusWotBinding(null!),
+ Throws.InstanceOf());
+ }
+
+ [Test]
+ public void AddModbusWotBindingRegistersBindersAndModbusExecutor()
+ {
+ var services = new ServiceCollection();
+ IOpcUaBuilder builder = new TestBuilder(services);
+ builder.AddModbusWotBinding();
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ WotProtocolBinderRegistry registry = sp.GetRequiredService();
+ System.Collections.Generic.IEnumerable executors =
+ sp.GetServices();
+
+ Assert.That(registry.Binders, Is.Not.Empty);
+ Assert.That(executors.Any(e => e is ModbusWotBindingExecutor), Is.True);
+ }
+
+ [Test]
+ public void AddModbusWotBindingWithConfigureDelegateCallsDelegate()
+ {
+ IOpcUaBuilder builder = NewBuilder();
+ bool called = false;
+
+ builder.AddModbusWotBinding(opts => called = true);
+
+ Assert.That(called, Is.True);
+ }
+
+ [Test]
+ public void AddOpcUaWotBindingNullBuilderThrowsArgumentNullException()
+ {
+ Assert.That(
+ () => OpcUaTargetWotBindingBuilderExtensions.AddOpcUaWotBinding(null!, _ => { }),
+ Throws.InstanceOf());
+ }
+
+ [Test]
+ public void AddOpcUaWotBindingNullConfigureThrowsArgumentNullException()
+ {
+ IOpcUaBuilder builder = NewBuilder();
+
+ Assert.That(
+ () => builder.AddOpcUaWotBinding(null!),
+ Throws.InstanceOf());
+ }
+
+ [Test]
+ public void AddOpcUaWotBindingWithConfigureDelegateCallsDelegate()
+ {
+ IOpcUaBuilder builder = NewBuilder();
+ bool called = false;
+
+ builder.AddOpcUaWotBinding(opts =>
+ {
+ called = true;
+ opts.DisposeSession = false;
+ });
+
+ Assert.That(called, Is.True);
+ }
+
+ [Test]
+ public void AddOpcUaWotBindingRegistersBindersAndOpcUaExecutor()
+ {
+ var services = new ServiceCollection();
+ IOpcUaBuilder builder = new TestBuilder(services);
+ builder.AddOpcUaWotBinding(opts => opts.DisposeSession = false);
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ WotProtocolBinderRegistry registry = sp.GetRequiredService();
+ System.Collections.Generic.IEnumerable executors =
+ sp.GetServices();
+
+ Assert.That(registry.Binders, Is.Not.Empty);
+ Assert.That(executors.Any(e => e is OpcUaWotBindingExecutor), Is.True);
+ }
+
+ [Test]
+ public void AddMqttWotBindingNullBuilderThrowsArgumentNullException()
+ {
+ Assert.That(
+ () => OpcUaMqttWotBindingBuilderExtensions.AddMqttWotBinding(null!),
+ Throws.InstanceOf());
+ }
+
+ [Test]
+ public void AddMqttWotBindingRegistersBindersAndMqttExecutor()
+ {
+ var services = new ServiceCollection();
+ IOpcUaBuilder builder = new TestBuilder(services);
+ builder.AddMqttWotBinding();
+ using ServiceProvider sp = services.BuildServiceProvider();
+
+ WotProtocolBinderRegistry registry = sp.GetRequiredService();
+ System.Collections.Generic.IEnumerable executors =
+ sp.GetServices();
+
+ Assert.That(registry.Binders, Is.Not.Empty);
+ Assert.That(executors.Any(e => e is MqttWotBindingExecutor), Is.True);
+ }
+
+ [Test]
+ public void AddMqttWotBindingWithConfigureDelegateCallsDelegate()
+ {
+ IOpcUaBuilder builder = NewBuilder();
+ bool called = false;
+
+ builder.AddMqttWotBinding(opts => called = true);
+
+ Assert.That(called, Is.True);
+ }
+
[Test]
public void RegistryResolvesBindersBothAsIWotBinderRegistryAndIWotBindingChannelFactory()
{
diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs
new file mode 100644
index 0000000000..9bfd744146
--- /dev/null
+++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingChannelTests.cs
@@ -0,0 +1,446 @@
+/* ========================================================================
+ * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
+ *
+ * OPC Foundation MIT License 1.00
+ *
+ * Permission is hereby granted, free of charge, to any person
+ * obtaining a copy of this software and associated documentation
+ * files (the "Software"), to deal in the Software without
+ * restriction, including without limitation the rights to use,
+ * copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the
+ * Software is furnished to do so, subject to the following
+ * conditions:
+ *
+ * The above copyright notice and this permission notice shall be
+ * included in all copies or substantial portions of the Software.
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+ * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
+ * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+ * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+ * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+ * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+ * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
+ * OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * The complete license agreement can be found here:
+ * http://opcfoundation.org/License/MIT/1.00/
+ * ======================================================================*/
+
+using System;
+using System.Collections.Concurrent;
+using System.Globalization;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using NUnit.Framework;
+using Opc.Ua.Client;
+using Opc.Ua.Client.TestFramework;
+using Opc.Ua.Server.TestFramework;
+using Opc.Ua.Tests;
+using Opc.Ua.WotCon.Bindings.OpcUa;
+using Opc.Ua.WotCon.Bindings.Planners;
+using Quickstarts.ReferenceServer;
+
+namespace Opc.Ua.WotCon.Bindings.Tests
+{
+ ///
+ /// Covers previously-uncovered paths in OpcUaWotBindingChannel against a
+ /// live in-process OPC UA reference server: invalid / malformed NodeId resolution
+ /// returning BadNodeIdInvalid, null-callback argument guards, null-inputs
+ /// mapping, non-Good DataValue preservation, extra event fields in
+ /// BuildEventFilter, and ServiceResultException propagation from
+ /// failed subscription creation.
+ ///
+ [TestFixture]
+ public sealed class OpcUaWotBindingChannelTests
+ {
+ private const string ReferenceServerNamespace =
+ "http://opcfoundation.org/Quickstarts/ReferenceServer";
+
+ private const string AddMethodNodeId =
+ "nsu=" + ReferenceServerNamespace + ";s=Methods_Add";
+
+ private const string MethodsObjectNodeId =
+ "nsu=" + ReferenceServerNamespace + ";s=Methods";
+
+ private const string TriggerNode01Id =
+ "nsu=" + ReferenceServerNamespace + ";s=NodeIds_Events_TriggerNode01";
+
+ private const string ServerObjectNodeId =
+ "nsu=http://opcfoundation.org/UA/;i=2253";
+
+ ///
+ /// A string that cannot be parsed as a NodeId or ExpandedNodeId so that
+ /// TryResolveNodeId returns false.
+ ///
+ private const string InvalidNodeId = "INVALID-NOT-A-NODE";
+
+ private ServerFixture m_serverFixture = null!;
+ private ISession m_session = null!;
+ private WotProtocolBinderRegistry m_registry = null!;
+ private WotBindingPlan m_plan = null!;
+
+ ///
+ /// Starts an in-process reference server, connects a single shared session,
+ /// and compiles a Thing Description that exposes edge-case affordances for every
+ /// uncovered channel path.
+ ///
+ [OneTimeSetUp]
+ public async Task OneTimeSetUpAsync()
+ {
+ ITelemetryContext telemetry = NUnitTelemetryContext.Create();
+ string pkiRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
+ m_serverFixture = new ServerFixture(t => new ReferenceServer(t))
+ {
+ UriScheme = "opc.tcp",
+ SecurityNone = true,
+ AutoAccept = true,
+ AllNodeManagers = true
+ };
+ await m_serverFixture.StartAsync(pkiRoot).ConfigureAwait(false);
+
+ var clientFixture = new ClientFixture(telemetry);
+ await clientFixture.LoadClientConfigurationAsync(pkiRoot).ConfigureAwait(false);
+ var url = new Uri("opc.tcp://localhost:" +
+ m_serverFixture.Port.ToString(CultureInfo.InvariantCulture));
+ m_session = await clientFixture.ConnectAsync(url, SecurityPolicies.None).ConfigureAwait(false);
+
+ m_registry = new WotProtocolBinderRegistry(
+ [new OpcUaBindingPlanner()],
+ [
+ new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions
+ {
+ SessionFactory = (endpoint, ct) => new ValueTask(m_session),
+ DisposeSession = false,
+ ObserveInterval = TimeSpan.FromMilliseconds(100)
+ })
+ ],
+ endpointPolicy: new WotEndpointPolicy { AllowLoopback = true });
+
+ m_plan = m_registry.Prepare(WotBindingPlanRequest.FromDocument(
+ "xid", WoTDocumentKindEnum.ThingDescription,
+ System.Text.Encoding.UTF8.GetBytes(BuildThingDescription(url.ToString()))));
+
+ Assert.That(m_plan.Diagnostics.Any(d => d.IsError), Is.False,
+ "The channel-test Thing Description must compile without diagnostic errors: " +
+ string.Join("; ", m_plan.Diagnostics.Where(d => d.IsError).Select(d => d.Message)));
+ }
+
+ ///
+ /// Closes the shared session and stops the server.
+ ///
+ [OneTimeTearDown]
+ public async Task OneTimeTearDownAsync()
+ {
+ if (m_session is not null)
+ {
+ await m_session.CloseAsync().ConfigureAwait(false);
+ m_session.Dispose();
+ }
+
+ if (m_serverFixture is not null)
+ {
+ await m_serverFixture.StopAsync().ConfigureAwait(false);
+ }
+ }
+
+ [Test]
+ public async Task ReadAsyncWithMalformedNodeIdReturnsBadNodeIdInvalidAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False,
+ "A malformed NodeId must produce a BadNodeIdInvalid read result.");
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid));
+ }
+ }
+
+ [Test]
+ public async Task ReadAsyncWithNonExistentNodeIdPreservesServerBadStatusAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "nonexistent" && f.Operation == WoTBindingCapabilityEnum.ReadProperty);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotReadResult result = await channel.ReadAsync().ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False,
+ "Reading a well-formed but non-existent NodeId must return a bad DataValue status.");
+ Assert.That(StatusCode.IsBad(result.Status), Is.True);
+ }
+ }
+
+ [Test]
+ public async Task WriteAsyncWithMalformedNodeIdReturnsBadNodeIdInvalidAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.WriteProperty);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(42)))
+ .ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False,
+ "A malformed NodeId must produce a BadNodeIdInvalid write result.");
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid));
+ }
+ }
+
+ [Test]
+ public async Task InvokeAsyncWithoutComponentOfMetadataReturnsBadNodeIdInvalidAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "nocomponentof" &&
+ f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False);
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid));
+ Assert.That(result.Error, Does.Contain("uav:componentOf"),
+ "The error message must name the missing uav:componentOf field.");
+ }
+ }
+
+ [Test]
+ public async Task InvokeAsyncWithInvalidComponentOfNodeIdReturnsBadNodeIdInvalidAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "badcomponentof" &&
+ f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False,
+ "An unparseable uav:componentOf NodeId must produce BadNodeIdInvalid.");
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid));
+ }
+ }
+
+ [Test]
+ public async Task InvokeAsyncWithInvalidMethodNodeIdReturnsBadNodeIdInvalidAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "badmethodid" &&
+ f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ WotInvokeResult result = await channel.InvokeAsync([]).ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False,
+ "An unparseable method NodeId must produce BadNodeIdInvalid.");
+ Assert.That(result.Status, Is.EqualTo(StatusCodes.BadNodeIdInvalid));
+ }
+ }
+
+ [Test]
+ public async Task InvokeAsyncWithNullInputsPassesEmptyArgumentsAndMapsServerErrorAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "nullinputs" &&
+ f.Operation == WoTBindingCapabilityEnum.InvokeAction);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ // null inputs → 'inputs is null ? [] : [.. inputs]' takes the empty-array branch;
+ // Methods_Add requires 2 arguments so the server rejects the call → bad result.
+ WotInvokeResult result = await channel.InvokeAsync(null!).ConfigureAwait(false);
+
+ Assert.That(result.Success, Is.False,
+ "Calling Methods_Add with null (zero) inputs must fail server-side and return a bad result.");
+ }
+ }
+
+ [Test]
+ public async Task ObserveAsyncWithNullCallbackThrowsArgumentNullExceptionAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.ObserveProperty);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ Assert.That(
+ async () => await channel.ObserveAsync(null!).ConfigureAwait(false),
+ Throws.InstanceOf());
+ }
+ }
+
+ [Test]
+ public async Task ObserveAsyncWithMalformedNodeIdThrowsServiceResultExceptionAsync()
+ {
+ WotCompiledForm form = m_plan.CompiledForms.First(
+ f => f.AffordanceName == "badid" && f.Operation == WoTBindingCapabilityEnum.ObserveProperty);
+
+ IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false);
+ await using (channel.ConfigureAwait(false))
+ {
+ Assert.That(
+ async () => await channel.ObserveAsync(_ => { }).ConfigureAwait(false),
+ Throws.InstanceOf