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()); + } + } + + [Test] + public async Task SubscribeEventAsyncWithNullCallbackThrowsArgumentNullExceptionAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badevent" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Assert.That( + async () => await channel.SubscribeEventAsync(null!).ConfigureAwait(false), + Throws.InstanceOf()); + } + } + + [Test] + public async Task SubscribeEventAsyncWithMalformedNodeIdThrowsServiceResultExceptionAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "badevent" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + Assert.That( + async () => await channel.SubscribeEventAsync(_ => { }).ConfigureAwait(false), + Throws.InstanceOf()); + } + } + + [Test] + public async Task SubscribeEventAsyncWithExtraEventFieldsIncludesFieldInNotificationAsync() + { + WotCompiledForm form = m_plan.CompiledForms.First( + f => f.AffordanceName == "extrafields" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(form).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .SubscribeEventAsync(received.Enqueue).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + NodeId triggerNodeId = ResolvePortableNodeId(TriggerNode01Id); + var writeValue = new WriteValue + { + NodeId = triggerNodeId, + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(77)) + }; + WriteResponse writeResponse = await m_session + .WriteAsync(null, new WriteValue[] { writeValue }, CancellationToken.None) + .ConfigureAwait(false); + Assert.That(StatusCode.IsGood(writeResponse.Results[0]), Is.True, + "Writing the trigger node must succeed to fire a BaseEvent."); + + WotNotification? notification = null; + for (int i = 0; i < 100 && notification is null; i++) + { + if (!received.TryDequeue(out notification)) + { + await Task.Delay(50).ConfigureAwait(false); + } + } + + Assert.That(notification, Is.Not.Null, + "The extra-fields subscription must deliver the triggered event."); + Assert.That(notification!.EventFields.ContainsKey("LocalTime"), Is.True, + "The 'LocalTime' extra uav:eventFields select clause must appear in EventFields."); + } + } + } + + /// + /// Builds a Thing Description JSON that includes edge-case affordances for every + /// uncovered OpcUaWotBindingChannel path. + /// + private static string BuildThingDescription(string endpoint) + { + return + "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"title\":\"channel-tests\"," + + "\"properties\":{" + + // badid: malformed NodeId for ReadProperty / WriteProperty / ObserveProperty + "\"badid\":{\"type\":\"integer\",\"observable\":true,\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"readproperty\"]}," + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"writeproperty\"]}," + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"observeproperty\"]}" + + "]}," + + // nonexistent: valid NodeId format but no such node on the server + "\"nonexistent\":{\"type\":\"integer\",\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"i=99999999\",\"op\":[\"readproperty\"]}" + + "]}}," + + "\"actions\":{" + + // nocomponentof: missing uav:componentOf → InvokeAsync returns BadNodeIdInvalid + "\"nocomponentof\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"i=2258\",\"op\":[\"invokeaction\"]}" + + "]}," + + // badcomponentof: uav:componentOf is an invalid NodeId string + "\"badcomponentof\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"i=2258\"," + + "\"uav:componentOf\":\"" + InvalidNodeId + "\",\"op\":[\"invokeaction\"]}" + + "]}," + + // badmethodid: valid componentOf but invalid method NodeId + "\"badmethodid\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"uav:componentOf\":\"" + MethodsObjectNodeId + "\",\"op\":[\"invokeaction\"]}" + + "]}," + + // nullinputs: real method invoked with null inputs → empty args → server error + "\"nullinputs\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + AddMethodNodeId + "\"," + + "\"uav:componentOf\":\"" + MethodsObjectNodeId + "\",\"op\":[\"invokeaction\"]}" + + "]}}," + + "\"events\":{" + + // badevent: malformed NodeId for SubscribeEvent + "\"badevent\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + InvalidNodeId + "\"," + + "\"op\":[\"subscribeevent\"]}" + + "]}," + + // extrafields: real event notifier with uav:eventFields → exercises BuildEventFilter + "\"extrafields\":{\"forms\":[" + + "{\"href\":\"" + endpoint + "\",\"uav:id\":\"" + ServerObjectNodeId + "\"," + + "\"op\":[\"subscribeevent\"],\"uav:eventFields\":[\"LocalTime\"]}" + + "]}}}"; + } + + /// + /// Resolves a portable nsu= NodeId string against the connected session's + /// namespace table. + /// + private NodeId ResolvePortableNodeId(string value) + { + var expanded = ExpandedNodeId.Parse(value); + return ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs new file mode 100644 index 0000000000..24ae731bde --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotBindingExecutorUnitTests.cs @@ -0,0 +1,108 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Immutable; +using System.Threading.Tasks; +using NUnit.Framework; +using Opc.Ua.Client; +using Opc.Ua.WotCon.Bindings.OpcUa; +using Opc.Ua.WotCon.Bindings.Planners; + +namespace Opc.Ua.WotCon.Bindings.Tests +{ + /// + /// Unit tests for endpoint construction in . + /// + [TestFixture] + public sealed class OpcUaWotBindingExecutorUnitTests + { + [TestCase("opc.tcp", 4841, "opc.tcp://example.test:4841")] + [TestCase("opc.tcp", 4840, "opc.tcp://example.test")] + [TestCase("opc.https", 443, "opc.https://example.test")] + public async Task ActivateAsyncBuildsEndpointFromHostAndPortWhenBaseUriIsEmptyAsync( + string scheme, int port, string expected) + { + string? capturedEndpoint = null; + var executor = new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => + { + capturedEndpoint = endpoint; + return ValueTask.FromException(new InvalidOperationException("stop")); + } + }); + + WotCompiledForm form = BuildForm(scheme, port, baseUri: string.Empty); + + Assert.That( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(capturedEndpoint, Is.EqualTo(expected)); + } + + [Test] + public async Task ActivateAsyncPreservesExplicitBaseUriAsync() + { + string? capturedEndpoint = null; + var executor = new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => + { + capturedEndpoint = endpoint; + return ValueTask.FromException(new InvalidOperationException("stop")); + } + }); + + WotCompiledForm form = BuildForm("opc.tcp", 4841, "opc.tcp://actual.example:1111"); + + Assert.That( + async () => await executor.ActivateAsync(form, new WotExecutorContext()).ConfigureAwait(false), + Throws.InstanceOf()); + Assert.That(capturedEndpoint, Is.EqualTo("opc.tcp://actual.example:1111")); + } + + private static WotCompiledForm BuildForm(string scheme, int port, string baseUri) + { + return new WotCompiledForm( + new WotBindingIdentity("opc.opcua", "10101", OpcUaBindingPlanner.BindingUri), + WotAffordanceKind.Property, + "p", + "/properties/p/forms/0", + WoTBindingCapabilityEnum.ReadProperty, + "readproperty", + new WotEndpointDescriptor(scheme, "example.test", port, baseUri), + new WotAddressingDescriptor("i=2258"), + new WotOperationDescriptor(WoTBindingCapabilityEnum.ReadProperty, "readproperty", "Read"), + new WotPayloadDescriptor("application/json", "json"), + ImmutableArray.Empty, + isExecutable: true); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs new file mode 100644 index 0000000000..bd842a3779 --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/OpcUaWotExecutorTests.cs @@ -0,0 +1,385 @@ +/* ======================================================================== + * 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 +{ + /// + /// End-to-end tests proving OPC UA-to-OPC UA translation: a WoT Thing + /// Description describing an OPC UA target is compiled and executed + /// against a real in-process OPC UA server (a reference server). The + /// fixture starts the server and connects a single session once, and + /// each test exercises one operation: readproperty, writeproperty with + /// readback, observeproperty (native data-change subscription), + /// invokeaction (a real Method with ordered input/output arguments, + /// resolved through uav:componentOf) and subscribeevent (a real + /// EventNotifier and an emitted event, asserting selected fields). The + /// counter, action and event forms address their NodeIds with the + /// portable nsu= form to prove namespace-table resolution. + /// + [TestFixture] + public sealed class OpcUaWotExecutorTests + { + /// + /// Server_ServerStatus_CurrentTime (readable UtcTime) and + /// Server_ServerStatus_State (a read-only Int32) are standard nodes + /// every OPC UA server exposes; they need no namespace resolution. + /// + private const string CurrentTimeNodeId = "i=2258"; + private const string StateNodeId = "i=2259"; + + /// + /// The ReferenceServer's own namespace; nodes below are addressed with + /// the portable nsu= form so resolution goes through the session's + /// namespace table rather than a guessed namespace index. + /// + private const string ReferenceServerNamespace = "http://opcfoundation.org/Quickstarts/ReferenceServer"; + private const string CounterNodeId = "nsu=" + ReferenceServerNamespace + ";s=Scalar_Static_Int32"; + private const string AddMethodNodeId = "nsu=" + ReferenceServerNamespace + ";s=Methods_Add"; + private const string MethodsObjectNodeId = "nsu=" + ReferenceServerNamespace + ";s=Methods"; + private const string TriggerNode01Id = "nsu=" + ReferenceServerNamespace + ";s=NodeIds_Events_TriggerNode01"; + + /// + /// The standard Server object (i=2253, ns=0) addressed portably too + /// (nsu= for the base namespace); every ReportEvent call in this + /// stack reports starting at the Server object, so it always receives + /// events regardless of where the event's SourceNode lives. + /// + private const string ServerObjectNodeId = "nsu=http://opcfoundation.org/UA/;i=2253"; + + private ServerFixture m_serverFixture = null!; + private ISession m_session = null!; + private WotProtocolBinderRegistry m_registry = null!; + private WotBindingPlan m_plan = null!; + + [OneTimeSetUp] + public async Task OneTimeSetUpAsync() + { + ITelemetryContext telemetry = NUnitTelemetryContext.Create(); + string pkiRoot = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + + m_serverFixture = new ServerFixture(t => new ReferenceServer(t)) + { + UriScheme = "opc.tcp", + SecurityNone = true, + AutoAccept = true, + AllNodeManagers = true + }; + await m_serverFixture.StartAsync(pkiRoot).ConfigureAwait(false); + + var clientFixture = new ClientFixture(telemetry); + await clientFixture.LoadClientConfigurationAsync(pkiRoot).ConfigureAwait(false); + var url = new Uri("opc.tcp://localhost:" + m_serverFixture.Port.ToString(CultureInfo.InvariantCulture)); + m_session = await clientFixture.ConnectAsync(url, SecurityPolicies.None).ConfigureAwait(false); + + m_registry = new WotProtocolBinderRegistry( + [new OpcUaBindingPlanner()], + [ + new OpcUaWotBindingExecutor(new OpcUaWotBindingOptions + { + SessionFactory = (endpoint, ct) => new ValueTask(m_session), + DisposeSession = false, + ObserveInterval = TimeSpan.FromMilliseconds(100) + }) + ], + endpointPolicy: new WotEndpointPolicy { AllowLoopback = true }); + + string ep = url.ToString(); + string td = BuildThingDescription(ep); + m_plan = m_registry.Prepare(WotBindingPlanRequest.FromDocument( + "xid", WoTDocumentKindEnum.ThingDescription, System.Text.Encoding.UTF8.GetBytes(td))); + + Assert.That(m_plan.Diagnostics.Any(d => d.IsError), Is.False, + "The Thing Description must compile without diagnostic errors: " + + string.Join("; ", m_plan.Diagnostics.Where(d => d.IsError).Select(d => d.Message))); + } + + [OneTimeTearDown] + public async Task OneTimeTearDownAsync() + { + if (m_session is not null) + { + await m_session.CloseAsync().ConfigureAwait(false); + m_session.Dispose(); + } + if (m_serverFixture is not null) + { + await m_serverFixture.StopAsync().ConfigureAwait(false); + } + } + + [Test] + public async Task ReadPropertyReturnsRealServerValueAsync() + { + WotCompiledForm read = m_plan.CompiledForms.First( + f => f.AffordanceName == "time" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotReadResult result = await channel.ReadAsync().ConfigureAwait(false); + Assert.That(result.Success, Is.True, "Reading a real OPC UA node must succeed."); + Assert.That(result.Value.WrappedValue.AsBoxedObject(), Is.Not.Null); + } + } + + [Test] + public async Task WritePropertyReadOnlyNodeMapsBadStatusAsync() + { + WotCompiledForm write = m_plan.CompiledForms.First( + f => f.AffordanceName == "state" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotWriteResult result = await channel.WriteAsync(new DataValue(new Variant(1))).ConfigureAwait(false); + Assert.That(result.Success, Is.False, + "Writing a read-only OPC UA node must be translated and its bad status mapped."); + } + } + + [Test] + public async Task WritePropertyWithReadbackRoundTripsThroughPortableNodeIdAsync() + { + const int expected = 4242; + WotCompiledForm write = m_plan.CompiledForms.First( + f => f.AffordanceName == "counter" && f.Operation == WoTBindingCapabilityEnum.WriteProperty); + WotCompiledForm read = m_plan.CompiledForms.First( + f => f.AffordanceName == "counter" && f.Operation == WoTBindingCapabilityEnum.ReadProperty); + + IWotBindingChannel writeChannel = await m_registry.OpenChannelAsync(write).ConfigureAwait(false); + await using (writeChannel.ConfigureAwait(false)) + { + WotWriteResult writeResult = await writeChannel + .WriteAsync(new DataValue(new Variant(expected))).ConfigureAwait(false); + Assert.That(writeResult.Success, Is.True, + $"Writing the counter property (portable nsu= NodeId) must succeed: {writeResult.Error}"); + } + + IWotBindingChannel readChannel = await m_registry.OpenChannelAsync(read).ConfigureAwait(false); + await using (readChannel.ConfigureAwait(false)) + { + WotReadResult readResult = await readChannel.ReadAsync().ConfigureAwait(false); + Assert.That(readResult.Success, Is.True, + $"Reading back the counter property must succeed: {readResult.Error}"); + Assert.That(readResult.Value.WrappedValue.TryGetValue(out int actual), Is.True); + Assert.That(actual, Is.EqualTo(expected), + "The read-back value must match the value written through the binding."); + } + } + + [Test] + public async Task ObservePropertyDeliversNotificationViaNativeSubscriptionAsync() + { + WotCompiledForm observe = m_plan.CompiledForms.First( + f => f.Operation == WoTBindingCapabilityEnum.ObserveProperty); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(observe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .ObserveAsync(n => received.Enqueue(n.Value.WrappedValue.AsBoxedObject())).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + bool got = false; + for (int i = 0; i < 100 && !got; i++) + { + got = !received.IsEmpty; + await Task.Delay(50).ConfigureAwait(false); + } + Assert.That(got, Is.True, + "The observe channel must deliver a value from the server via a native MonitoredItem."); + } + } + } + + [Test] + public async Task InvokeActionRealMethodReturnsOrderedOutputArgumentsAsync() + { + WotCompiledForm invoke = m_plan.CompiledForms.First( + f => f.AffordanceName == "add" && f.Operation == WoTBindingCapabilityEnum.InvokeAction); + + IWotBindingChannel channel = await m_registry.OpenChannelAsync(invoke).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + WotInvokeResult result = await channel + .InvokeAsync([new(2.5f), new(3u)]).ConfigureAwait(false); + Assert.That(result.Success, Is.True, + "Invoking the real 'Methods_Add' method (resolved via uav:componentOf) " + + $"must succeed: {result.Error}"); + Assert.That(result.Outputs, Has.Count.EqualTo(1)); + Assert.That(result.Outputs[0].WrappedValue.TryGetValue(out float sum), Is.True); + Assert.That(sum, Is.EqualTo(5.5f).Within(0.0001f), + "The Add method sums its Float and UInt32 arguments in order."); + } + } + + [Test] + public async Task SubscribeEventRealEventNotifierDeliversSelectedFieldsAsync() + { + WotCompiledForm subscribe = m_plan.CompiledForms.First( + f => f.AffordanceName == "trigger" && f.Operation == WoTBindingCapabilityEnum.SubscribeEvent); + + var received = new ConcurrentQueue(); + IWotBindingChannel channel = await m_registry.OpenChannelAsync(subscribe).ConfigureAwait(false); + await using (channel.ConfigureAwait(false)) + { + IWotSubscription subscription = await channel + .SubscribeEventAsync(received.Enqueue).ConfigureAwait(false); + await using (subscription.ConfigureAwait(false)) + { + NodeId triggerNodeId = ResolvePortableNodeId(TriggerNode01Id); + var write = new WriteValue + { + NodeId = triggerNodeId, + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(42)) + }; + WriteResponse writeResponse = await m_session + .WriteAsync(null, new WriteValue[] { write }, CancellationToken.None).ConfigureAwait(false); + Assert.That(StatusCode.IsGood(writeResponse.Results[0]), Is.True, + "Writing the trigger node must succeed and fire a BaseEvent."); + + WotNotification? notification = null; + for (int i = 0; i < 100 && notification is null; i++) + { + if (!received.TryDequeue(out notification)) + { + await Task.Delay(50).ConfigureAwait(false); + } + } + Assert.That(notification, Is.Not.Null, + "The subscribeevent channel must deliver the event triggered by the write."); + + Assert.That(notification!.EventFields.TryGetValue("EventId", out DataValue eventIdValue), Is.True); + Assert.That(eventIdValue.WrappedValue.TryGetValue(out ByteString eventId), Is.True); + Assert.That(eventId.Length, Is.GreaterThan(0), "EventId must be a non-empty identifier."); + + Assert.That( + notification.EventFields.TryGetValue("EventType", out DataValue eventTypeValue), Is.True); + Assert.That(eventTypeValue.WrappedValue.TryGetValue(out NodeId eventType), Is.True); + Assert.That(eventType, Is.EqualTo(Types.ObjectTypeIds.BaseEventType)); + + Assert.That( + notification.EventFields.TryGetValue("SourceNode", out DataValue sourceNodeValue), Is.True); + Assert.That(sourceNodeValue.WrappedValue.TryGetValue(out NodeId sourceNode), Is.True); + Assert.That(sourceNode, Is.EqualTo(triggerNodeId), + "SourceNode must be the trigger variable that raised the event."); + + Assert.That(notification.EventFields.TryGetValue("Severity", out DataValue severityValue), Is.True); + Assert.That(severityValue.WrappedValue.TryGetValue(out ushort severity), Is.True); + Assert.That(severity, Is.EqualTo((ushort)EventSeverity.Medium)); + + Assert.That(notification.EventFields.TryGetValue("Message", out DataValue messageValue), Is.True); + Assert.That(messageValue.WrappedValue.TryGetValue(out LocalizedText message), Is.True); + Assert.That(message.Text, Does.Contain("Trigger event")); + + // The primary DataValue carries the same Message text with a + // Good status and the event's own Time / ReceiveTime. + Assert.That(StatusCode.IsGood(notification.Value.StatusCode), Is.True); + Assert.That(notification.Value.WrappedValue.TryGetValue(out LocalizedText primaryMessage), Is.True); + Assert.That(primaryMessage.Text, Does.Contain("Trigger event")); + } + } + } + + private static string BuildThingDescription(string endpoint) + { + return "{\"@context\":\"https://www.w3.org/2022/wot/td/v1.1\",\"@type\":\"uav:object\"," + + "\"title\":\"t\",\"properties\":{" + + "\"time\":{\"type\":\"string\",\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CurrentTimeNodeId + + "\"}]}," + + "\"watch\":{\"type\":\"string\",\"observable\":true,\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CurrentTimeNodeId + + "\",\"op\":[\"observeproperty\"]}]}," + + "\"state\":{\"type\":\"integer\",\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + StateNodeId + + "\"}]}," + + "\"counter\":{\"type\":\"integer\",\"forms\":[" + + "{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CounterNodeId + + "\",\"op\":[\"writeproperty\"]}," + + "{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + CounterNodeId + + "\",\"op\":[\"readproperty\"]}" + + "]}}," + + "\"actions\":{\"add\":{\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + AddMethodNodeId + + "\",\"uav:componentOf\":\"" + + MethodsObjectNodeId + + "\",\"op\":[\"invokeaction\"]}]}}," + + "\"events\":{\"trigger\":{\"forms\":[{\"href\":\"" + + endpoint + + "\",\"uav:id\":\"" + + ServerObjectNodeId + + "\",\"op\":[\"subscribeevent\"]}]}}}"; + } + + /// + /// Resolves a portable nsu= NodeId string directly against the + /// connected session's namespace table (mirroring the fallback the + /// executor itself uses), so the test can address the trigger + /// variable without hard-coding a namespace index. + /// + private NodeId ResolvePortableNodeId(string value) + { + var expanded = ExpandedNodeId.Parse(value); + return ExpandedNodeId.ToNodeId(expanded, m_session.NamespaceUris); + } + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs new file mode 100644 index 0000000000..4cdf72c16d --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestHttpServer.cs @@ -0,0 +1,301 @@ +/* ======================================================================== + * Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved. + * + * OPC Foundation MIT License 1.00 + * + * Permission is hereby granted, free of charge, to any person + * obtaining a copy of this software and associated documentation + * files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following + * conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, + * EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES + * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND + * NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT + * HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, + * WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING + * FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR + * OTHER DEALINGS IN THE SOFTWARE. + * + * The complete license agreement can be found here: + * http://opcfoundation.org/License/MIT/1.00/ + * ======================================================================*/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Tests.Support +{ + /// + /// A single response returned by the in-process test HTTP server. + /// + public sealed class TestHttpResponse + { + public TestHttpResponse( + int status, string contentType, byte[] body, + IReadOnlyDictionary? headers = null) + { + Status = status; + ContentType = contentType; + Body = body; + Headers = headers; + } + + public int Status { get; } + + public string ContentType { get; } + + public byte[] Body { get; } + + /// + /// Gets optional extra response headers (for example Location). + /// + public IReadOnlyDictionary? Headers { get; } + + public static TestHttpResponse Json(int status, string json) + { + return new TestHttpResponse(status, "application/json", Encoding.UTF8.GetBytes(json)); + } + + /// + /// Creates a redirect response (default 302) carrying a Location header. + /// + public static TestHttpResponse Redirect(string location, int status = 302) + { + return new TestHttpResponse(status, "text/plain", [], + new Dictionary(StringComparer.OrdinalIgnoreCase) { ["Location"] = location }); + } + } + + /// + /// A parsed request handed to the richer test-server handler. + /// + public sealed class TestHttpRequest + { + public TestHttpRequest(string method, string path, byte[] body, IReadOnlyDictionary headers) + { + Method = method; + Path = path; + Body = body; + Headers = headers; + } + + public string Method { get; } + + public string Path { get; } + + public byte[] Body { get; } + + public IReadOnlyDictionary Headers { get; } + } + + /// + /// A minimal in-process HTTP/1.1 server built on + /// (avoiding HttpListener URL-ACL requirements). It routes each request + /// to a supplied handler and is used for the HTTP executor end-to-end tests. + /// + public sealed class TestHttpServer : IDisposable + { + public TestHttpServer(Func handler) + : this(request => handler(request.Method, request.Path, request.Body)) + { + } + + public TestHttpServer(Func handler) + { + m_handler = handler; + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + BaseUrl = $"http://127.0.0.1:{Port}"; + m_loop = Task.Run(AcceptLoopAsync); + ObserveLoopFaults(m_loop); + } + + public string BaseUrl { get; } + + public int Port { get; } + + public void Dispose() + { + Volatile.Write(ref m_stopped, 1); + m_listener.Stop(); + m_listener.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!IsStopped) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + _ = Task.Run(() => HandleAsync(client)); + } + } + + private bool IsStopped => Volatile.Read(ref m_stopped) != 0; + + private static void ObserveLoopFaults(Task loop) + { + _ = loop.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private async Task HandleAsync(TcpClient client) + { + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + TestHttpRequest? request = await ReadRequestAsync(stream).ConfigureAwait(false); + if (request is null) + { + return; + } + TestHttpResponse response = m_handler(request); + await WriteResponseAsync(stream, response).ConfigureAwait(false); + } + catch (IOException) + { + // Client disconnected. + } + } + } + + private static async Task ReadRequestAsync(NetworkStream stream) + { + var header = new MemoryStream(); + byte[] one = new byte[1]; + int matched = 0; + byte[] terminator = Encoding.ASCII.GetBytes("\r\n\r\n"); + while (matched < terminator.Length) + { + int read = await stream.ReadAsync(one.AsMemory(0, 1)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + header.WriteByte(one[0]); + matched = one[0] == terminator[matched] ? matched + 1 : (one[0] == terminator[0] ? 1 : 0); + } + + string[] lines = Encoding.ASCII.GetString(header.ToArray()).Split("\r\n"); + string[] requestLine = lines[0].Split(' '); + string method = requestLine.Length > 0 ? requestLine[0] : "GET"; + string path = requestLine.Length > 1 ? requestLine[1] : "/"; + int contentLength = 0; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int i = 1; i < lines.Length; i++) + { + string line = lines[i]; + int colon = line.IndexOf(':', StringComparison.Ordinal); + if (colon <= 0) + { + continue; + } + string name = line[..colon].Trim(); + string value = line[(colon + 1)..].Trim(); + headers[name] = value; + if (string.Equals(name, "Content-Length", StringComparison.OrdinalIgnoreCase) && + !int.TryParse(value, out contentLength)) + { + contentLength = 0; + } + } + + byte[] body = []; + if (contentLength > 0) + { + body = new byte[contentLength]; + int offset = 0; + while (offset < contentLength) + { + int read = await stream + .ReadAsync(body.AsMemory(offset, contentLength - offset)) + .ConfigureAwait(false); + if (read == 0) + { + break; + } + offset += read; + } + } + return new TestHttpRequest(method, path, body, headers); + } + + private static async Task WriteResponseAsync(NetworkStream stream, TestHttpResponse response) + { + byte[] body = response.Body ?? []; + var builder = new StringBuilder(); + builder.Append("HTTP/1.1 ").Append(response.Status).Append(' ') + .Append(Reason(response.Status)).Append("\r\n") + .Append("Content-Type: ").Append(response.ContentType).Append("\r\n") + .Append("Content-Length: ").Append(body.Length).Append("\r\n"); + if (response.Headers is { Count: > 0 }) + { + foreach (KeyValuePair extra in response.Headers) + { + builder.Append(extra.Key).Append(": ").Append(extra.Value).Append("\r\n"); + } + } + builder.Append("Connection: close\r\n\r\n"); + byte[] head = Encoding.ASCII.GetBytes(builder.ToString()); + await stream.WriteAsync(head).ConfigureAwait(false); + if (body.Length > 0) + { + await stream.WriteAsync(body).ConfigureAwait(false); + } + await stream.FlushAsync().ConfigureAwait(false); + } + + private static string Reason(int status) + { + return status switch + { + 200 => "OK", + 204 => "No Content", + 301 => "Moved Permanently", + 302 => "Found", + 303 => "See Other", + 307 => "Temporary Redirect", + 308 => "Permanent Redirect", + 400 => "Bad Request", + 404 => "Not Found", + 500 => "Internal Server Error", + _ => "Status" + }; + } + + private readonly Func m_handler; + private readonly TcpListener m_listener; + private readonly Task m_loop; + private int m_stopped; + } +} diff --git a/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs new file mode 100644 index 0000000000..0aabb456ec --- /dev/null +++ b/tests/Opc.Ua.WotCon.Bindings.Tests/Support/TestModbusServer.cs @@ -0,0 +1,303 @@ +/* ======================================================================== + * 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.Net; +using System.Net.Sockets; +using System.Threading; +using System.Threading.Tasks; + +namespace Opc.Ua.WotCon.Bindings.Tests.Support +{ + /// + /// A minimal in-process Modbus TCP server / simulator supporting the read and + /// write function codes required by the WoT Modbus binding (FC 1/2/3/4/5/6/15/16). + /// + public sealed class TestModbusServer : IDisposable + { + public TestModbusServer() + { + m_listener = new TcpListener(IPAddress.Loopback, 0); + m_listener.Start(); + Port = ((IPEndPoint)m_listener.LocalEndpoint).Port; + m_loop = Task.Run(AcceptLoopAsync); + ObserveLoopFaults(m_loop); + } + + public int Port { get; } + + public ushort[] HoldingRegisters { get; } = new ushort[1024]; + + public ushort[] InputRegisters { get; } = new ushort[1024]; + + public bool[] Coils { get; } = new bool[2048]; + + public bool[] DiscreteInputs { get; } = new bool[2048]; + + public bool RejectConnections + { + get => Volatile.Read(ref m_rejectConnections) != 0; + set => Volatile.Write(ref m_rejectConnections, value ? 1 : 0); + } + + public int AcceptedConnectionCount => Volatile.Read(ref m_acceptedConnectionCount); + + public int LastFunctionCode => Volatile.Read(ref m_lastFunctionCode); + + public void DisconnectClients() + { + foreach (TcpClient client in m_clients.Values) + { + client.Dispose(); + } + } + + public void Dispose() + { + Volatile.Write(ref m_stopped, 1); + DisconnectClients(); + m_listener.Stop(); + m_listener.Dispose(); + } + + private async Task AcceptLoopAsync() + { + while (!IsStopped) + { + TcpClient client; + try + { + client = await m_listener.AcceptTcpClientAsync().ConfigureAwait(false); + } + catch (ObjectDisposedException) + { + return; + } + catch (SocketException) + { + return; + } + int connectionId = Interlocked.Increment(ref m_acceptedConnectionCount); + if (RejectConnections) + { + client.Dispose(); + continue; + } + m_clients[connectionId] = client; + _ = ServeAsync(connectionId); + } + } + + private async Task ServeAsync(int connectionId) + { + try + { + if (!m_clients.TryGetValue(connectionId, out TcpClient? client)) + { + return; + } + using (client) + using (NetworkStream stream = client.GetStream()) + { + try + { + while (!IsStopped) + { + byte[]? header = await ReadExactAsync(stream, 6).ConfigureAwait(false); + if (header is null) + { + return; + } + int length = (header[4] << 8) | header[5]; + byte[]? rest = await ReadExactAsync(stream, length).ConfigureAwait(false); + if (rest is null) + { + return; + } + byte unit = rest[0]; + byte[] pdu = new byte[rest.Length - 1]; + Array.Copy(rest, 1, pdu, 0, pdu.Length); + byte[] responsePdu = Process(pdu); + byte[] frame = BuildFrame(header[0], header[1], unit, responsePdu); + await stream.WriteAsync(frame).ConfigureAwait(false); + await stream.FlushAsync().ConfigureAwait(false); + } + } + catch (System.IO.IOException) + { + // Client disconnected. + } + catch (ObjectDisposedException) + { + // Client disconnected. + } + } + } + finally + { + m_clients.TryRemove(connectionId, out _); + } + } + + private byte[] Process(byte[] pdu) + { + byte function = pdu[0]; + Volatile.Write(ref m_lastFunctionCode, function); + switch (function) + { + case 0x01: + return ReadBits(pdu, Coils, function); + case 0x02: + return ReadBits(pdu, DiscreteInputs, function); + case 0x03: + return ReadRegisters(pdu, HoldingRegisters, function); + case 0x04: + return ReadRegisters(pdu, InputRegisters, function); + case 0x05: + { + int address = (pdu[1] << 8) | pdu[2]; + Coils[address] = pdu[3] == 0xFF; + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + case 0x06: + { + int address = (pdu[1] << 8) | pdu[2]; + HoldingRegisters[address] = (ushort)((pdu[3] << 8) | pdu[4]); + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + case 0x0F: + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + for (int i = 0; i < quantity; i++) + { + Coils[address + i] = (pdu[6 + (i / 8)] & (1 << (i % 8))) != 0; + } + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + case 0x10: + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + for (int i = 0; i < quantity; i++) + { + HoldingRegisters[address + i] = (ushort)((pdu[6 + (i * 2)] << 8) | pdu[7 + (i * 2)]); + } + return [function, pdu[1], pdu[2], pdu[3], pdu[4]]; + } + default: + return [(byte)(function | 0x80), 0x01]; + } + } + + private static byte[] ReadRegisters(byte[] pdu, ushort[] store, byte function) + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + byte[] response = new byte[2 + (quantity * 2)]; + response[0] = function; + response[1] = (byte)(quantity * 2); + for (int i = 0; i < quantity; i++) + { + response[2 + (i * 2)] = (byte)(store[address + i] >> 8); + response[3 + (i * 2)] = (byte)(store[address + i] & 0xFF); + } + return response; + } + + private static byte[] ReadBits(byte[] pdu, bool[] store, byte function) + { + int address = (pdu[1] << 8) | pdu[2]; + int quantity = (pdu[3] << 8) | pdu[4]; + int byteCount = (quantity + 7) / 8; + byte[] response = new byte[2 + byteCount]; + response[0] = function; + response[1] = (byte)byteCount; + for (int i = 0; i < quantity; i++) + { + if (store[address + i]) + { + response[2 + (i / 8)] |= (byte)(1 << (i % 8)); + } + } + return response; + } + + private static byte[] BuildFrame(byte txnHi, byte txnLo, byte unit, byte[] pdu) + { + int length = pdu.Length + 1; + byte[] frame = new byte[7 + pdu.Length]; + frame[0] = txnHi; + frame[1] = txnLo; + frame[2] = 0x00; + frame[3] = 0x00; + frame[4] = (byte)(length >> 8); + frame[5] = (byte)(length & 0xFF); + frame[6] = unit; + Array.Copy(pdu, 0, frame, 7, pdu.Length); + return frame; + } + + private static async Task ReadExactAsync(NetworkStream stream, int count) + { + byte[] buffer = new byte[count]; + int offset = 0; + while (offset < count) + { + int read = await stream.ReadAsync(buffer.AsMemory(offset, count - offset)).ConfigureAwait(false); + if (read == 0) + { + return null; + } + offset += read; + } + return buffer; + } + + private bool IsStopped => Volatile.Read(ref m_stopped) != 0; + + private static void ObserveLoopFaults(Task loop) + { + _ = loop.ContinueWith( + static task => _ = task.Exception, + CancellationToken.None, + TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private readonly TcpListener m_listener; + private readonly Task m_loop; + private readonly ConcurrentDictionary m_clients = new(); + private int m_acceptedConnectionCount; + private int m_lastFunctionCode; + private int m_rejectConnections; + private int m_stopped; + } +}