diff --git a/docs/UAFX.md b/docs/UAFX.md
index bebabdd08a..28e57fc4b9 100644
--- a/docs/UAFX.md
+++ b/docs/UAFX.md
@@ -42,9 +42,10 @@ structures. `PubSubReserveCommunicationIds2DataType` and
`Opc.Ua.PubSub.Server` provides the experimental `PubSubFxReservationMethods`
binding for the `VerifyAssetCmd`, `VerifyFunctionalEntityCmd`, and
-`ReserveCommunicationIdsCmd` commands of `EstablishConnections`. It can also
-bind `CloseConnections` to a host-owned `IPubSubFxConnectionLifecycle`
-transaction. The host implements that capability because it owns the endpoint
+`ReserveCommunicationIdsCmd` commands of `EstablishConnections`. It can
+delegate the remaining `EstablishConnections` commands and bind
+`CloseConnections` to a host-owned `IPubSubFxConnectionLifecycle`
+implementation. The host supplies that capability because it owns the endpoint
nodes, ConnectionManager authorization, and shared-resource ownership. The
binding delegates
asset verification to each addressed `IAssetRevisionType.VerifyAsset` method,
@@ -84,23 +85,32 @@ The server's PubSub node manager forwards Session closure to the reservation
provider. A standalone host must perform that forwarding itself, as described
in [PubSub communication-ID reservations](PubSubCommunicationIds.md).
Registration does not enable configuration methods, expose the model, replace
-an existing handler, or implement endpoint creation/configuration/enablement.
+an existing handler, or provide a concrete host lifecycle implementation.
Registering a lifecycle capability causes `Bind` to install the generated
`CloseConnections` Method handler only when that Method has no existing handler.
+When an `EstablishConnections` call selects endpoint lifecycle commands, the
+binding invokes the same capability after all selected verification and
+reservation commands succeed. The host processes the selected endpoint
+commands in Part 81 order and returns one ordered endpoint result per request.
+The capability receives the ordered verification and communication-ID
+reservation results so the host can use reserved identifiers during endpoint
+configuration.
Calls require an actual Session with the `ConfigureAdmin` role and
-`ExposeConfigurationMethods = true`. The accepted command masks are any
-non-empty combination of `VerifyAssetCmd`, `VerifyFunctionalEntityCmd`, and
-`ReserveCommunicationIdsCmd`; other command bits fail explicitly. Each set
-command requires a non-empty input array, and each unset command requires an
-empty array. `VerifyFunctionalEntityCmd` requires at least one non-empty
-`ExpectedVerificationVariables` array. Its endpoint entries must select the
-`ConnectionEndpointDefinitionDataType.Node` member; control groups,
-configuration data, and communication links must remain unset because their
-commands are not implemented by this binding. The other endpoint command
-inputs and communication-configuration inputs must also remain empty. A call
-is limited to 128 combined asset-verification, endpoint-verification, and
-reservation requests.
+`ExposeConfigurationMethods = true`. The binding accepts non-empty
+combinations of the nine defined `EstablishConnections` command bits. A
+command that creates or changes endpoints requires a registered
+`IPubSubFxConnectionLifecycle`; without one, those command bits fail with
+`BadInvalidState` before verification or reservation begins. Each selected
+command requires its input, and each unselected command requires an empty
+input. `VerifyFunctionalEntityCmd` requires at least one non-empty
+`ExpectedVerificationVariables` array. Endpoint definitions select the Node
+member unless `CreateConnectionEndpointCmd` supplies a Parameter member.
+`ControlGroups`, `ConfigurationData`, and `CommunicationLinks` are accepted
+only with their corresponding control, configuration-data, or communication
+configuration commands. `SetCommunicationConfigurationCmd` requires one
+communication-configuration input. A call is limited to 128 combined
+asset-verification, endpoint, and reservation requests.
Asset verification preserves input order, invokes each asset's
`VerifyAsset` method with the supplied mode and expected-variable arrays, and
@@ -231,25 +241,27 @@ Generated C# is produced during compilation and is not checked in.
## Implementation boundary
These are model contracts, not a complete ConnectionManager or
-AutomationComponent lifecycle implementation. The binding above provides only
-the documented verification and reservation operations; applications remain
-responsible for authorization, lifecycle orchestration, endpoint association,
-enablement, status binding, persistence and cleanup according to Part 81 and
-their selected Part 84 profiles. Loading a model or completing a Method call is
-not evidence of valid PubSub DataSet reception or UAFX conformance.
+AutomationComponent lifecycle implementation. The binding above implements
+verification and reservation and delegates endpoint lifecycle commands to an
+optional host capability. Applications remain responsible for supplying that
+capability and for endpoint association, status binding, persistence, and
+cleanup according to Part 81 and their selected Part 84 profiles. Loading a
+model or completing a Method call is not evidence of valid PubSub DataSet
+reception or UAFX conformance.
`Opc.Ua.PubSub.Server` can load these declarations into its node manager when
`PubSubServerOptions.ExposeFxModel` is enabled. This is opt-in and registers
the generated encodeable types and the DI, FX Data, AutomationComponent, and
ConnectionManager namespaces. Model exposure alone does not bind
`EstablishConnections` or `CloseConnections`. The binding above implements
-asset and functional-entity verification plus communication-ID reservation;
-`CloseConnections` is delegated to the optional host transaction. Endpoint
-creation, control, configuration, and enablement remain unimplemented.
+asset and functional-entity verification plus communication-ID reservation.
+`EstablishConnections` endpoint commands and `CloseConnections` are delegated
+to the optional host capability. This package does not provide a concrete host
+lifecycle implementation for endpoint creation, control, configuration, or
+enablement.
The experimental [Data Channel binding](DataChannels.md) provides the
`ConfigureUafx` transport-configuration bridge. It does not install Part 81
configuration or grant a ConnectionManager authority over authenticated data
-peers. The `EstablishConnections` binding above supports asset verification,
-functional-entity verification, and communication-ID reservation only; it does
-not implement endpoint creation, control, configuration, or enablement.
+peers. A host lifecycle capability must still implement and authorize the
+endpoint operations delegated by `EstablishConnections` and `CloseConnections`.
diff --git a/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs b/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs
index d9e3ceadbd..f213f6ad6f 100644
--- a/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs
+++ b/src/Opc.Ua.PubSub.Server/DependencyInjection/PubSubServerBuilderExtensions.cs
@@ -51,8 +51,8 @@ namespace Microsoft.Extensions.DependencyInjection
{
///
/// Registers injectable Part 81 reservation methods for host-owned AutomationComponents.
- /// A host lifecycle capability registered with is also used
- /// to bind the component's CloseConnections Method.
+ /// A host lifecycle capability registered with handles
+ /// endpoint lifecycle commands and the component's CloseConnections Method.
///
[global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId,
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
@@ -70,7 +70,8 @@ namespace Microsoft.Extensions.DependencyInjection
}
///
- /// Registers the host-owned, list-level lifecycle transaction used by Part 81 CloseConnections.
+ /// Registers the host-owned lifecycle capability used by Part 81 endpoint commands and
+ /// CloseConnections.
///
/// PubSub server builder.
/// Host implementation that authorizes and transacts endpoint lifecycle.
diff --git a/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs b/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs
index fe0c8a3a4c..608d0a9623 100644
--- a/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs
+++ b/src/Opc.Ua.PubSub.Server/IPubSubFxConnectionLifecycle.cs
@@ -30,6 +30,7 @@
using System.Threading;
using System.Threading.Tasks;
using Opc.Ua.Fx.Ac;
+using Opc.Ua.Fx.Data;
namespace Opc.Ua.PubSub.Server
{
@@ -37,13 +38,48 @@ namespace Opc.Ua.PubSub.Server
/// Provides host-owned ConnectionEndpoint lifecycle transactions for an AutomationComponent.
///
///
- /// Implementations authorize the actual ConnectionManager represented by the call context and process the complete
- /// endpoint list as one transaction. They preserve unrelated and shared resources, prevent automatic readmission
- /// when removing an endpoint, and return one ordered result per requested endpoint. This capability does not
- /// transfer ownership of host nodes or PubSub runtime components to the PubSub server.
+ /// Implementations authorize the actual ConnectionManager represented by the call context. EstablishConnections
+ /// commands are processed in Part 81 order and return ordered endpoint results. CloseConnections processes the
+ /// complete endpoint list as one transaction, preserves unrelated and shared resources, and prevents automatic
+ /// readmission when removing an endpoint. This capability does not transfer ownership of host nodes or PubSub
+ /// runtime components to the PubSub server.
///
public interface IPubSubFxConnectionLifecycle
{
+ ///
+ /// Processes the host-owned endpoint commands in an EstablishConnections call.
+ ///
+ ///
+ /// The implementation processes selected commands in Part 81 order, starting with
+ /// CreateConnectionEndpointCmd and ending with EnableCommunicationCmd. It preserves the supplied
+ /// functional-entity verification results, returns ordered per-endpoint results, performs atomic operations
+ /// where required by Part 81, and does not take ownership of pre-existing or shared host resources.
+ ///
+ /// The actual Method call context, including its authenticated Session.
+ /// The host-owned AutomationComponent whose Method was called.
+ /// The complete EstablishConnections command mask.
+ /// The ordered endpoint configuration requests.
+ ///
+ /// Ordered results produced by earlier verification commands in the same Method call.
+ ///
+ ///
+ /// Ordered communication-ID reservations completed earlier in the same Method call.
+ ///
+ ///
+ /// Communication-model configuration supplied for SetCommunicationConfigurationCmd.
+ ///
+ /// Cancels processing where the host can safely honor cancellation.
+ /// Overall status and the ordered endpoint and communication-configuration results.
+ ValueTask EstablishConnectionsAsync(
+ ISystemContext context,
+ AutomationComponentState component,
+ uint commandMask,
+ ArrayOf connectionEndpointConfigurations,
+ ArrayOf initialEndpointResults,
+ ArrayOf reservationResults,
+ ArrayOf communicationConfigurations,
+ CancellationToken cancellationToken = default);
+
///
/// Closes or removes the specified host-owned ConnectionEndpoints in one lifecycle transaction.
///
@@ -62,4 +98,45 @@ namespace Opc.Ua.PubSub.Server
bool remove,
CancellationToken cancellationToken = default);
}
+
+ ///
+ /// Contains the result of host-owned Part 81 EstablishConnections endpoint commands.
+ ///
+ public sealed class PubSubFxEstablishConnectionsResult
+ {
+ ///
+ /// Initializes a result containing the overall method status and ordered command result arrays.
+ ///
+ /// The overall EstablishConnections status.
+ ///
+ /// Per-endpoint results in the request order.
+ ///
+ ///
+ /// Communication-configuration result array; one item when the command is processed.
+ ///
+ public PubSubFxEstablishConnectionsResult(
+ ServiceResult serviceResult,
+ ArrayOf connectionEndpointConfigurationResults,
+ ArrayOf communicationConfigurationResults)
+ {
+ ServiceResult = serviceResult;
+ ConnectionEndpointConfigurationResults = connectionEndpointConfigurationResults;
+ CommunicationConfigurationResults = communicationConfigurationResults;
+ }
+
+ ///
+ /// Gets the overall EstablishConnections status.
+ ///
+ public ServiceResult ServiceResult { get; }
+
+ ///
+ /// Gets the per-endpoint results in request order.
+ ///
+ public ArrayOf ConnectionEndpointConfigurationResults { get; }
+
+ ///
+ /// Gets the communication-configuration result array.
+ ///
+ public ArrayOf CommunicationConfigurationResults { get; }
+ }
}
diff --git a/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs b/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs
index c3be9466d2..733fe0905e 100644
--- a/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs
+++ b/src/Opc.Ua.PubSub.Server/PubSubFxReservationMethods.cs
@@ -41,10 +41,8 @@ using Opc.Ua.Server;
namespace Opc.Ua.PubSub.Server
{
///
- /// Binds supported Part 81 verification and reservation commands to the application's Session-owned Part 14
- /// transaction, and optionally delegates CloseConnections to a host-owned lifecycle transaction. Other
- /// EstablishConnections commands and ConnectionEndpoint creation/configuration are not implemented by this
- /// binding.
+ /// Binds Part 81 verification and reservation commands to the application's Session-owned Part 14 transaction,
+ /// and delegates endpoint lifecycle commands to an optional host-owned capability.
///
[global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId,
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
@@ -64,7 +62,8 @@ namespace Opc.Ua.PubSub.Server
/// The PubSub application supporting communication-ID reservations.
/// The PubSub server options governing configuration method authorization.
///
- /// Optional host capability that atomically closes or removes ConnectionEndpoints.
+ /// Optional host capability that processes endpoint lifecycle commands and closes or removes
+ /// ConnectionEndpoints.
///
public PubSubFxReservationMethods(
IPubSubApplication application,
@@ -127,7 +126,8 @@ namespace Opc.Ua.PubSub.Server
method.OnCallAsync = (context, calledMethod, objectId, commandMask, assets, endpoints,
reservations, configurations, cancellationToken) =>
EstablishConnectionsAsync(context, objectId, component, componentId, commandMask,
- assets, endpoints, reservations, configurations, listeningEndpoint, cancellationToken);
+ assets, endpoints, reservations, configurations, listeningEndpoint, m_connectionLifecycle,
+ cancellationToken);
if (m_connectionLifecycle is { } connectionLifecycle)
{
CloseConnectionsMethodState closeMethod = component.CloseConnections!;
@@ -224,6 +224,7 @@ namespace Opc.Ua.PubSub.Server
ArrayOf reservations,
ArrayOf configurations,
Func>? listeningEndpoint,
+ IPubSubFxConnectionLifecycle? connectionLifecycle,
CancellationToken cancellationToken)
{
var result = new EstablishConnectionsMethodStateResult
@@ -252,8 +253,18 @@ namespace Opc.Ua.PubSub.Server
uint verifyAssetCommand = (uint)FxCommandMask.VerifyAssetCmd;
uint verifyFunctionalEntityCommand = (uint)FxCommandMask.VerifyFunctionalEntityCmd;
uint reserveCommunicationIdsCommand = (uint)FxCommandMask.ReserveCommunicationIdsCmd;
- if ((commandMask & ~(verifyAssetCommand |
- verifyFunctionalEntityCommand | reserveCommunicationIdsCommand)) != 0)
+ uint createConnectionEndpointCommand = (uint)FxCommandMask.CreateConnectionEndpointCmd;
+ uint establishControlCommand = (uint)FxCommandMask.EstablishControlCmd;
+ uint setConfigurationDataCommand = (uint)FxCommandMask.SetConfigurationDataCmd;
+ uint reassignControlCommand = (uint)FxCommandMask.ReassignControlCmd;
+ uint setCommunicationConfigurationCommand =
+ (uint)FxCommandMask.SetCommunicationConfigurationCmd;
+ uint enableCommunicationCommand = (uint)FxCommandMask.EnableCommunicationCmd;
+ uint supportedCommands = verifyAssetCommand | verifyFunctionalEntityCommand |
+ reserveCommunicationIdsCommand | createConnectionEndpointCommand | establishControlCommand |
+ setConfigurationDataCommand | reassignControlCommand |
+ setCommunicationConfigurationCommand | enableCommunicationCommand;
+ if ((commandMask & ~supportedCommands) != 0)
{
result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidState);
return result;
@@ -261,11 +272,92 @@ namespace Opc.Ua.PubSub.Server
bool verifyAssets = (commandMask & verifyAssetCommand) != 0;
bool verifyFunctionalEntities = (commandMask & verifyFunctionalEntityCommand) != 0;
bool reserveCommunicationIds = (commandMask & reserveCommunicationIdsCommand) != 0;
+ bool createConnectionEndpoints = (commandMask & createConnectionEndpointCommand) != 0;
+ bool establishControl = (commandMask & establishControlCommand) != 0;
+ bool setConfigurationData = (commandMask & setConfigurationDataCommand) != 0;
+ bool reassignControl = (commandMask & reassignControlCommand) != 0;
+ bool setCommunicationConfiguration = (commandMask & setCommunicationConfigurationCommand) != 0;
+ bool enableCommunication = (commandMask & enableCommunicationCommand) != 0;
+ bool hasEndpointCommands = verifyFunctionalEntities || createConnectionEndpoints || establishControl ||
+ setConfigurationData || reassignControl || setCommunicationConfiguration || enableCommunication;
+ bool hasLifecycleCommands = createConnectionEndpoints || establishControl || setConfigurationData ||
+ reassignControl || setCommunicationConfiguration || enableCommunication;
+ if (hasLifecycleCommands && connectionLifecycle is null)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidState);
+ return result;
+ }
if ((verifyAssets && assets.IsEmpty) || (!verifyAssets && !assets.IsEmpty) ||
- (verifyFunctionalEntities && endpoints.IsEmpty) ||
- (!verifyFunctionalEntities && !endpoints.IsEmpty) || !configurations.IsEmpty ||
+ (hasEndpointCommands && endpoints.IsEmpty) || (!hasEndpointCommands && !endpoints.IsEmpty) ||
(reserveCommunicationIds && reservations.IsEmpty) ||
- (!reserveCommunicationIds && !reservations.IsEmpty))
+ (!reserveCommunicationIds && !reservations.IsEmpty) ||
+ (setCommunicationConfiguration && configurations.Count != 1) ||
+ (!setCommunicationConfiguration && !configurations.IsEmpty))
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ bool hasExpectedVariables = false;
+ bool hasEndpointParameter = false;
+ bool hasControlGroup = false;
+ bool hasConfigurationData = false;
+ bool hasCommunicationLinks = false;
+ for (int i = 0; i < endpoints.Count; i++)
+ {
+ ConnectionEndpointConfigurationDataType endpoint = endpoints[i];
+ hasExpectedVariables |= !endpoint.ExpectedVerificationVariables.IsEmpty;
+ hasControlGroup |= !endpoint.ControlGroups.IsEmpty;
+ hasConfigurationData |= !endpoint.ConfigurationData.IsEmpty;
+ bool endpointHasCommunicationLinks = endpoint.CommunicationLinks is not null &&
+ endpoint.CommunicationLinks.GetType() != typeof(CommunicationLinkConfigurationDataType);
+ hasCommunicationLinks |= endpointHasCommunicationLinks;
+ switch (endpoint.ConnectionEndpoint.SwitchField)
+ {
+ case ConnectionEndpointDefinitionDataTypeFields.Node:
+ if (endpoint.ConnectionEndpoint.Node.IsNull)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ break;
+ case ConnectionEndpointDefinitionDataTypeFields.Parameter:
+ hasEndpointParameter = true;
+ if (!createConnectionEndpoints)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ break;
+ default:
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ if (!verifyFunctionalEntities && !endpoint.ExpectedVerificationVariables.IsEmpty)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ if (!establishControl && !reassignControl && !endpoint.ControlGroups.IsEmpty)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ if (!setConfigurationData && !endpoint.ConfigurationData.IsEmpty)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ if (!setCommunicationConfiguration && endpointHasCommunicationLinks)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
+ return result;
+ }
+ }
+ if ((verifyFunctionalEntities && !hasExpectedVariables) ||
+ (createConnectionEndpoints && !hasEndpointParameter) ||
+ ((establishControl || reassignControl) && !hasControlGroup) ||
+ (setConfigurationData && !hasConfigurationData) ||
+ (setCommunicationConfiguration && !hasCommunicationLinks))
{
result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
return result;
@@ -287,31 +379,6 @@ namespace Opc.Ua.PubSub.Server
return result;
}
}
- if (verifyFunctionalEntities)
- {
- bool hasExpectedVariables = false;
- for (int i = 0; i < endpoints.Count; i++)
- {
- ConnectionEndpointConfigurationDataType endpoint = endpoints[i];
- hasExpectedVariables |= !endpoint.ExpectedVerificationVariables.IsEmpty;
- if (endpoint.ConnectionEndpoint.SwitchField !=
- ConnectionEndpointDefinitionDataTypeFields.Node ||
- endpoint.ConnectionEndpoint.Node.IsNull ||
- !endpoint.ControlGroups.IsEmpty ||
- !endpoint.ConfigurationData.IsEmpty ||
- (endpoint.CommunicationLinks is not null &&
- endpoint.CommunicationLinks.GetType() != typeof(CommunicationLinkConfigurationDataType)))
- {
- result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
- return result;
- }
- }
- if (!hasExpectedVariables)
- {
- result.ServiceResult = new ServiceResult(StatusCodes.BadInvalidArgument);
- return result;
- }
- }
if (assets.Count > kMaxRequestsPerCall ||
endpoints.Count > kMaxRequestsPerCall - assets.Count ||
reservations.Count > kMaxRequestsPerCall - assets.Count - endpoints.Count)
@@ -425,6 +492,40 @@ namespace Opc.Ua.PubSub.Server
result.ServiceResult = new ServiceResult(StatusCodes.Uncertain);
}
}
+
+ if (!aborted && hasLifecycleCommands && connectionLifecycle is { } lifecycle)
+ {
+ PubSubFxEstablishConnectionsResult lifecycleResult =
+ await lifecycle.EstablishConnectionsAsync(
+ context,
+ component,
+ commandMask,
+ endpoints,
+ result.ConnectionEndpointConfigurationResults,
+ result.ReserveCommunicationIdsResults,
+ configurations,
+ cancellationToken).ConfigureAwait(false);
+ if (lifecycleResult is null)
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadUnexpectedError);
+ return result;
+ }
+ if (lifecycleResult.ConnectionEndpointConfigurationResults.IsNull ||
+ lifecycleResult.ConnectionEndpointConfigurationResults.Count != endpoints.Count ||
+ (setCommunicationConfiguration
+ ? lifecycleResult.CommunicationConfigurationResults.IsNull ||
+ lifecycleResult.CommunicationConfigurationResults.Count != 1
+ : !lifecycleResult.CommunicationConfigurationResults.IsEmpty))
+ {
+ result.ServiceResult = new ServiceResult(StatusCodes.BadUnexpectedError);
+ return result;
+ }
+ result.ConnectionEndpointConfigurationResults =
+ lifecycleResult.ConnectionEndpointConfigurationResults;
+ result.CommunicationConfigurationResults =
+ lifecycleResult.CommunicationConfigurationResults;
+ result.ServiceResult = lifecycleResult.ServiceResult;
+ }
}
catch (ServiceResultException exception)
{
@@ -515,7 +616,7 @@ namespace Opc.Ua.PubSub.Server
ArrayOf expectedVariables = endpoint.ExpectedVerificationVariables;
if (expectedVariables.IsEmpty)
{
- result.ConnectionEndpointId = endpoint.ConnectionEndpoint.Node;
+ result.ConnectionEndpointId = GetConnectionEndpointNodeId(endpoint);
result.VerificationStatus = StatusCodes.GoodNoData;
return result;
}
@@ -562,11 +663,21 @@ namespace Opc.Ua.PubSub.Server
(verificationResult != FunctionalEntityVerificationResultEnum.NotSet ||
verificationErrors.IsEmpty))
{
- result.ConnectionEndpointId = endpoint.ConnectionEndpoint.Node;
+ result.ConnectionEndpointId = GetConnectionEndpointNodeId(endpoint);
}
return result;
}
+ ///
+ /// Returns the existing endpoint NodeId when the union selects its Node member.
+ ///
+ private static NodeId GetConnectionEndpointNodeId(ConnectionEndpointConfigurationDataType endpoint)
+ {
+ return endpoint.ConnectionEndpoint.SwitchField == ConnectionEndpointDefinitionDataTypeFields.Node
+ ? endpoint.ConnectionEndpoint.Node
+ : NodeId.Null;
+ }
+
///
/// Confirms the FunctionalEntity is hosted by an Asset in the bound AutomationComponent.
///
diff --git a/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs
index 3cf14d4f91..e78654302f 100644
--- a/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs
+++ b/tests/Opc.Ua.PubSub.Server.Tests/OpcUaServerBuilderPubSubExtensionsTests.cs
@@ -30,12 +30,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Moq;
using NUnit.Framework;
+using Opc.Ua.Fx.Ac;
using Opc.Ua.PubSub.Application;
using Opc.Ua.PubSub.Security.Sks;
using Opc.Ua.PubSub.Server.Hosting;
@@ -453,6 +455,76 @@ namespace Opc.Ua.PubSub.Server.Tests
Assert.That(factory.NamespacesUris.ToArray(), Contains.Item(PubSubNodeManager.NamespaceUri));
}
+ [TestCase(true)]
+ [TestCase(false)]
+ public async Task FxDiResolvedBinderDispatchesRegisteredLifecycleAsync(bool registerLifecycleFirst)
+ {
+ ServiceCollection services = BuildServicesWithRuntime();
+ IPubSubServerBuilder builder = services.AddOpcUa().AddServer(_ => { })
+ .AddPubSub(options => options.ExposeConfigurationMethods = true);
+ var lifecycle = new Mock();
+ if (registerLifecycleFirst)
+ {
+ builder.WithFxConnectionLifecycle(lifecycle.Object);
+ builder.WithFxCommunicationIdReservations();
+ }
+ else
+ {
+ builder.WithFxCommunicationIdReservations();
+ builder.WithFxConnectionLifecycle(lifecycle.Object);
+ }
+
+ await using ServiceProvider provider = services.BuildServiceProvider();
+ PubSubFxReservationMethods binder = provider.GetRequiredService();
+ PubSubServerOptions registeredOptions =
+ provider.GetRequiredService>().Value;
+ Assert.Multiple(() =>
+ {
+ Assert.That(provider.GetRequiredService(), Is.SameAs(lifecycle.Object));
+ Assert.That(registeredOptions.ExposeConfigurationMethods, Is.True,
+ "The authorized call requires the explicitly configured method option.");
+ Assert.That(registeredOptions.ExposeFxModel, Is.False,
+ "Registering the FX binder and lifecycle must not enable the FX model.");
+ });
+
+ await using var harness = new PubSubCommunicationIdMethodTests.Harness(
+ options: new PubSubServerOptions { ExposeFxModel = true, ExposeConfigurationMethods = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ AutomationComponentState component = harness.CreateFxComponentWithCloseConnections();
+ CloseConnectionsMethodState method = component.CloseConnections!;
+ ArrayOf endpoints =
+ [new NodeId("endpoint:z", 1), new NodeId("endpoint:a", 1), new NodeId("endpoint:z", 1)];
+ ArrayOf expected =
+ [StatusCodes.BadInvalidState, StatusCodes.Good, StatusCodes.BadNodeIdUnknown];
+ const bool remove = true;
+ binder.Bind(component);
+
+ lifecycle.Setup(value => value.CloseConnectionsAsync(
+ It.IsAny(), It.IsAny(), It.IsAny>(),
+ It.IsAny(), It.IsAny()))
+ .Returns((ISystemContext context, AutomationComponentState actualComponent,
+ ArrayOf actualEndpoints, bool actualRemove, CancellationToken cancellationToken) =>
+ {
+ Assert.That(context, Is.SameAs(harness.Context));
+ Assert.That(actualComponent, Is.SameAs(component));
+ Assert.That(actualEndpoints, Is.EqualTo(endpoints));
+ Assert.That(actualRemove, Is.EqualTo(remove));
+ Assert.That(cancellationToken, Is.EqualTo(CancellationToken.None));
+ return new ValueTask>(expected);
+ });
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, [Variant.From(endpoints), Variant.From(remove)]).ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(outputs, Has.Count.EqualTo(1));
+ Assert.That(outputs[0].TryGetValue(out ArrayOf actualResults), Is.True);
+ Assert.That(actualResults, Is.EqualTo(expected));
+ lifecycle.Verify(value => value.CloseConnectionsAsync(
+ It.IsAny(), It.IsAny(), It.IsAny>(),
+ It.IsAny(), It.IsAny()), Times.Once);
+ }
+
internal static ServiceCollection BuildServicesWithRuntime()
{
var services = new ServiceCollection();
diff --git a/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs b/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs
index 5a81586691..793b32005b 100644
--- a/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs
+++ b/tests/Opc.Ua.PubSub.Server.Tests/PubSubCommunicationIdMethodTests.cs
@@ -2717,6 +2717,622 @@ namespace Opc.Ua.PubSub.Server.Tests
It.IsAny(), It.IsAny()), Times.Never);
}
+ [TestCase(4u)]
+ [TestCase(8u)]
+ [TestCase(16u)]
+ [TestCase(32u)]
+ [TestCase(128u)]
+ [TestCase(71u)]
+ [TestCase(75u)]
+ [TestCase(83u)]
+ [TestCase(99u)]
+ [TestCase(195u)]
+ public async Task FxUnsupportedLifecycleCommandsFailBeforeVerificationAndReservationAsync(uint commandMask)
+ {
+ var policy = new Mock();
+ policy.Setup(value => value.ResolveAsync(It.IsAny(), It.IsAny()))
+ .Returns((string _, CancellationToken _) => new ValueTask(Variant.From(7UL)));
+ await using var harness = new Harness(policy.Object,
+ new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ int resolverCalls = 0;
+ EstablishConnectionsMethodState method = harness.BindFx(_ =>
+ {
+ resolverCalls++;
+ return new ValueTask("opc.tcp://localhost:4840/uafx");
+ });
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ NodeId assetId = new("unsupported-command-asset", namespaceIndex);
+ _ = harness.AddVerifiableAsset(assetId, AssetVerificationResultEnum.Match);
+ NodeId functionalEntityId = new("unsupported-command-fe", namespaceIndex);
+ _ = harness.AddVerifiableFunctionalEntity(
+ functionalEntityId, FunctionalEntityVerificationResultEnum.Match);
+ var asset = new AssetVerificationDataType
+ {
+ AssetToVerify = assetId,
+ VerificationMode = AssetVerificationModeEnum.AssetCompatibility,
+ ExpectedVerificationResult = AssetVerificationResultEnum.Match
+ };
+ ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
+ functionalEntityId, [ExpectedVerificationVariable("expected", namespaceIndex)]);
+ PubSubReserveCommunicationIds2DataType request = FxRequest();
+ request.TransportProfileUri = DataChannelProfiles.PubSubUasc;
+ request.RequestTransportSpecificInfo = true;
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context,
+ FxArguments(commandMask, [asset], [endpoint], request)).ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
+ Assert.That(outputs, Is.Empty);
+ Assert.That(harness.VerifyAssetCalls, Is.Zero);
+ Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero);
+ Assert.That(resolverCalls, Is.Zero);
+ policy.Verify(value => value.ResolveAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+
+ (ServiceResult next, List nextOutputs) = await harness.CallAsync(
+ method, harness.Context, FxReserveArguments(FxRequest())).ConfigureAwait(false);
+ Assert.That(next.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(FxResults(harness, nextOutputs)[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 }));
+ }
+
+ [TestCase(0)]
+ [TestCase(1)]
+ [TestCase(2)]
+ public async Task FxLifecycleBindingRejectsInvalidCloseMethodWithoutPartialInstallationAsync(
+ int invalidMethodShape)
+ {
+ await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ AutomationComponentState component = harness.CreateFxComponentWithCloseConnections();
+ EstablishConnectionsMethodState establish = component.EstablishConnections!;
+ CloseConnectionsMethodState? close = invalidMethodShape switch
+ {
+ 0 => null,
+ 1 => new CloseConnectionsMethodState(component),
+ 2 => CreateMisparentedCloseMethod(harness),
+ _ => throw new ArgumentOutOfRangeException(nameof(invalidMethodShape))
+ };
+ component.CloseConnections = close;
+ NodeId originalComponentId = component.NodeId;
+ var lifecycle = new Mock();
+ var binding = new PubSubFxReservationMethods(
+ harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object);
+
+ Assert.That(() => binding.Bind(component), Throws.ArgumentException);
+
+ Assert.That(component.NodeId, Is.EqualTo(originalComponentId));
+ Assert.That(component.EstablishConnections, Is.SameAs(establish));
+ Assert.That(component.CloseConnections, Is.SameAs(close));
+ AssertNoMethodHandlers(establish);
+ if (close is not null)
+ {
+ AssertNoMethodHandlers(close);
+ }
+ lifecycle.Verify(value => value.CloseConnectionsAsync(
+ It.IsAny(), It.IsAny(), It.IsAny>(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task FxVerificationRejectsNullSelectedEndpointNodeBeforeCallingHostAsync()
+ {
+ var policy = new Mock();
+ policy.Setup(value => value.ResolveAsync(It.IsAny(), It.IsAny()))
+ .Returns((string _, CancellationToken _) => new ValueTask(Variant.From(7UL)));
+ await using var harness = new Harness(policy.Object,
+ new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ MethodState method = harness.BindFx();
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ NodeId functionalEntityId = new("null-endpoint-fe", namespaceIndex);
+ _ = harness.AddVerifiableFunctionalEntity(
+ functionalEntityId, FunctionalEntityVerificationResultEnum.Match);
+ var endpoint = FxEndpoint(
+ functionalEntityId, [ExpectedVerificationVariable("expected", namespaceIndex)]);
+ endpoint.ConnectionEndpoint = new ConnectionEndpointDefinitionDataType
+ {
+ SwitchField = ConnectionEndpointDefinitionDataTypeFields.Node,
+ Node = NodeId.Null
+ };
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, FxArguments(
+ (uint)FxCommandMask.VerifyFunctionalEntityCmd |
+ (uint)FxCommandMask.ReserveCommunicationIdsCmd,
+ ArrayOf.Empty, [endpoint], FxRequest()))
+ .ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidArgument));
+ Assert.That(outputs, Is.Empty);
+ Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero);
+ policy.Verify(value => value.ResolveAsync(It.IsAny(), It.IsAny()),
+ Times.Never);
+ (ServiceResult next, List nextOutputs) = await harness.CallAsync(
+ method, harness.Context, FxReserveArguments(FxRequest())).ConfigureAwait(false);
+ Assert.That(next.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(FxResults(harness, nextOutputs)[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 }));
+ }
+
+ [TestCase(0)]
+ [TestCase(1)]
+ [TestCase(2)]
+ [TestCase(3)]
+ public async Task FxCloseDispatchRejectsArityAndArgumentTypesBeforeLifecycleAsync(int invalidCall)
+ {
+ await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ AutomationComponentState component = harness.CreateFxComponentWithCloseConnections();
+ CloseConnectionsMethodState method = component.CloseConnections!;
+ var lifecycle = new Mock();
+ new PubSubFxReservationMethods(
+ harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object)
+ .Bind(component);
+ ArrayOf endpoints = [new NodeId("dispatch-endpoint", 4)];
+ ArrayOf arguments = invalidCall switch
+ {
+ 0 => [Variant.From(endpoints)],
+ 1 => [Variant.From(endpoints), Variant.From(false), Variant.From(1)],
+ 2 => [Variant.From("not-an-endpoint-array"), Variant.From(false)],
+ 3 => [Variant.From(endpoints), Variant.From("not-a-boolean")],
+ _ => throw new ArgumentOutOfRangeException(nameof(invalidCall))
+ };
+ var argumentErrors = new List();
+ var outputs = new List();
+
+ ServiceResult result = await method.CallAsync(
+ harness.Context, component.NodeId, arguments, argumentErrors, outputs).ConfigureAwait(false);
+
+ Assert.That(outputs, Is.Empty);
+ if (invalidCall == 0)
+ {
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadArgumentsMissing));
+ Assert.That(argumentErrors, Is.Empty);
+ }
+ else if (invalidCall == 1)
+ {
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTooManyArguments));
+ Assert.That(argumentErrors, Is.Empty);
+ }
+ else
+ {
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(argumentErrors, Has.Count.EqualTo(2));
+ Assert.That(argumentErrors[invalidCall == 2 ? 0 : 1].StatusCode,
+ Is.EqualTo(StatusCodes.BadTypeMismatch));
+ Assert.That(argumentErrors[invalidCall == 2 ? 1 : 0].StatusCode,
+ Is.EqualTo(StatusCodes.Good));
+ }
+ lifecycle.Verify(value => value.CloseConnectionsAsync(
+ It.IsAny(), It.IsAny(), It.IsAny>(),
+ It.IsAny(), It.IsAny()), Times.Never);
+ }
+
+ [Test]
+ public async Task FxAllSupportedCommandsExecuteInStageOrderAsync()
+ {
+ var events = new List();
+ var assetEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var functionalEntityEntered =
+ new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var reservationEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var assetGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var functionalEntityGate = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var policy = new Mock();
+ policy.Setup(value => value.ResolveAsync(
+ Profiles.PubSubUdpUadpTransport, It.IsAny()))
+ .Returns((string _, CancellationToken _) =>
+ {
+ events.Add("reservation");
+ reservationEntered.TrySetResult(true);
+ return new ValueTask(Variant.From(7UL));
+ });
+ await using var harness = new Harness(policy.Object,
+ new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ EstablishConnectionsMethodState method = harness.BindFx();
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ NodeId assetId = new("ordered-asset", namespaceIndex);
+ MethodState verifyAsset = harness.AddVerifiableAsset(assetId, AssetVerificationResultEnum.Match)
+ .FindChild(harness.Context, new QualifiedName("VerifyAsset",
+ namespaceIndex)) as MethodState ?? throw new InvalidOperationException();
+ verifyAsset.OnCallMethod2Async = async (_, _, _, _, outputs, _) =>
+ {
+ events.Add("asset");
+ assetEntered.TrySetResult(true);
+ await assetGate.Task.ConfigureAwait(false);
+ outputs[0] = Variant.From((int)AssetVerificationResultEnum.Match);
+ outputs[1] = Variant.From(ArrayOf.Empty);
+ outputs[2] = Variant.From(ArrayOf.Empty);
+ return ServiceResult.Good;
+ };
+ NodeId functionalEntityId = new("ordered-fe", namespaceIndex);
+ MethodState verifyFunctionalEntity = harness.AddVerifiableFunctionalEntity(
+ functionalEntityId, FunctionalEntityVerificationResultEnum.Match);
+ verifyFunctionalEntity.OnCallMethod2Async = async (_, _, _, _, outputs, _) =>
+ {
+ events.Add("functional-entity");
+ functionalEntityEntered.TrySetResult(true);
+ await functionalEntityGate.Task.ConfigureAwait(false);
+ outputs[0] = Variant.From((int)FunctionalEntityVerificationResultEnum.Match);
+ outputs[1] = Variant.From(ArrayOf.Empty);
+ return ServiceResult.Good;
+ };
+ var asset = new AssetVerificationDataType
+ {
+ AssetToVerify = assetId,
+ VerificationMode = AssetVerificationModeEnum.AssetCompatibility,
+ ExpectedVerificationResult = AssetVerificationResultEnum.Match,
+ ExpectedVerificationVariables = []
+ };
+ ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
+ functionalEntityId, [ExpectedVerificationVariable("ordered-variable", namespaceIndex)]);
+ Task<(ServiceResult Result, List Outputs)> pending = harness.CallAsync(
+ method, harness.Context, FxArguments(
+ (uint)FxCommandMask.VerifyAssetCmd |
+ (uint)FxCommandMask.VerifyFunctionalEntityCmd |
+ (uint)FxCommandMask.ReserveCommunicationIdsCmd,
+ [asset], [endpoint], FxRequest()));
+
+ try
+ {
+ await assetEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ Assert.That(string.Join(",", events), Is.EqualTo("asset"));
+ Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero);
+ Assert.That(reservationEntered.Task.IsCompleted, Is.False);
+ Assert.That(pending.IsCompleted, Is.False);
+ assetGate.SetResult(true);
+ await functionalEntityEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ Assert.That(string.Join(",", events), Is.EqualTo("asset,functional-entity"));
+ Assert.That(reservationEntered.Task.IsCompleted, Is.False);
+ Assert.That(pending.IsCompleted, Is.False);
+ functionalEntityGate.SetResult(true);
+ await reservationEntered.Task.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ (ServiceResult result, List outputs) =
+ await pending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+
+ Assert.That(string.Join(",", events), Is.EqualTo("asset,functional-entity,reservation"));
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(outputs, Has.Count.EqualTo(4));
+ AssetVerificationResultDataType[] assetResults = FxAssetResults(harness, outputs);
+ Assert.That(assetResults, Has.Length.EqualTo(1));
+ Assert.That(assetResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good));
+ Assert.That(assetResults[0].VerificationResult, Is.EqualTo(AssetVerificationResultEnum.Match));
+ ConnectionEndpointConfigurationResultDataType[] endpointResults =
+ FxEndpointResults(harness, outputs);
+ Assert.That(endpointResults, Has.Length.EqualTo(1));
+ Assert.That(endpointResults[0].FunctionalEntityNodeResult, Is.EqualTo(StatusCodes.Good));
+ Assert.That(endpointResults[0].ConnectionEndpointId, Is.EqualTo(endpoint.ConnectionEndpoint.Node));
+ Assert.That(endpointResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good));
+ Assert.That(endpointResults[0].VerificationResult,
+ Is.EqualTo(FunctionalEntityVerificationResultEnum.Match));
+ PubSubReserveCommunicationIdsResult2DataType[] reservationResults = FxResults(harness, outputs);
+ Assert.That(reservationResults, Has.Length.EqualTo(1));
+ Assert.That(reservationResults[0].Result, Is.EqualTo(StatusCodes.Good));
+ Assert.That(reservationResults[0].DefaultPublisherId.TryGetValue(out ulong publisher), Is.True);
+ Assert.That(publisher, Is.EqualTo(7UL));
+ Assert.That(reservationResults[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(reservationResults[0].DataSetWriterIds, Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(outputs[3].TryGetValue(
+ out ArrayOf configurations,
+ harness.Context.AsMessageContext()), Is.True);
+ Assert.That(configurations.IsEmpty, Is.True);
+ }
+ finally
+ {
+ assetGate.TrySetResult(true);
+ functionalEntityGate.TrySetResult(true);
+ await pending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ }
+ }
+
+ [TestCase(125)]
+ [TestCase(126)]
+ [TestCase(127)]
+ [TestCase(128)]
+ public async Task FxCombinedRequestBudgetIsCheckedBeforeAnyStageAsync(int functionalEntityCount)
+ {
+ var policy = new Mock();
+ policy.Setup(value => value.ResolveAsync(
+ Profiles.PubSubUdpUadpTransport, It.IsAny()))
+ .Returns(new ValueTask(Variant.From(7UL)));
+ await using var harness = new Harness(policy.Object,
+ new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ EstablishConnectionsMethodState method = harness.BindFx();
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ NodeId assetId = new("budget-asset", namespaceIndex);
+ _ = harness.AddVerifiableAsset(assetId, AssetVerificationResultEnum.Match);
+ var asset = new AssetVerificationDataType
+ {
+ AssetToVerify = assetId,
+ VerificationMode = AssetVerificationModeEnum.AssetCompatibility,
+ ExpectedVerificationResult = AssetVerificationResultEnum.Match
+ };
+ ConnectionEndpointConfigurationDataType[] endpoints =
+ Enumerable.Range(0, functionalEntityCount).Select(index =>
+ {
+ NodeId functionalEntityId = new($"budget-fe-{index}", namespaceIndex);
+ _ = harness.AddVerifiableFunctionalEntity(
+ functionalEntityId, FunctionalEntityVerificationResultEnum.Match);
+ return FxEndpoint(functionalEntityId,
+ [ExpectedVerificationVariable($"budget-variable-{index}", namespaceIndex)]);
+ }).ToArray();
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, FxArguments(
+ (uint)FxCommandMask.VerifyAssetCmd |
+ (uint)FxCommandMask.VerifyFunctionalEntityCmd |
+ (uint)FxCommandMask.ReserveCommunicationIdsCmd,
+ [asset], endpoints, FxRequest())).ConfigureAwait(false);
+
+ if (functionalEntityCount <= 126)
+ {
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(outputs, Has.Count.EqualTo(4));
+ Assert.That(harness.VerifyAssetCalls, Is.EqualTo(1));
+ Assert.That(harness.VerifyFunctionalEntityCalls, Is.EqualTo(functionalEntityCount));
+ AssetVerificationResultDataType[] assetResults = FxAssetResults(harness, outputs);
+ Assert.That(assetResults, Has.Length.EqualTo(1));
+ Assert.That(assetResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good));
+ Assert.That(assetResults[0].VerificationResult, Is.EqualTo(AssetVerificationResultEnum.Match));
+ ConnectionEndpointConfigurationResultDataType[] endpointResults =
+ FxEndpointResults(harness, outputs);
+ Assert.That(endpointResults, Has.Length.EqualTo(functionalEntityCount));
+ Assert.That(endpointResults.Select(value => value.ConnectionEndpointId),
+ Is.EqualTo(endpoints.Select(value => value.ConnectionEndpoint.Node)));
+ Assert.That(endpointResults.Select(value => value.FunctionalEntityNodeResult),
+ Is.All.EqualTo(StatusCodes.Good));
+ Assert.That(endpointResults.Select(value => value.VerificationStatus),
+ Is.All.EqualTo(StatusCodes.Good));
+ Assert.That(endpointResults.Select(value => value.VerificationResult),
+ Is.All.EqualTo(FunctionalEntityVerificationResultEnum.Match));
+ Assert.That(FxResults(harness, outputs), Has.Length.EqualTo(1));
+ Assert.That(FxResults(harness, outputs)[0].Result, Is.EqualTo(StatusCodes.Good));
+ Assert.That(FxResults(harness, outputs)[0].WriterGroupIds,
+ Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(FxResults(harness, outputs)[0].DataSetWriterIds,
+ Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(FxResults(harness, outputs)[0].DefaultPublisherId.TryGetValue(out ulong publisher),
+ Is.True);
+ Assert.That(publisher, Is.EqualTo(7UL));
+ }
+ else
+ {
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadTooManyOperations));
+ Assert.That(outputs, Is.Empty);
+ Assert.That(harness.VerifyAssetCalls, Is.Zero);
+ Assert.That(harness.VerifyFunctionalEntityCalls, Is.Zero);
+ policy.Verify(value => value.ResolveAsync(
+ It.IsAny(), It.IsAny()), Times.Never);
+ (ServiceResult next, List nextOutputs) = await harness.CallAsync(
+ method, harness.Context, FxReserveArguments(FxRequest())).ConfigureAwait(false);
+ Assert.That(next.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(FxResults(harness, nextOutputs), Has.Length.EqualTo(1));
+ Assert.That(FxResults(harness, nextOutputs)[0].Result, Is.EqualTo(StatusCodes.Good));
+ Assert.That(FxResults(harness, nextOutputs)[0].WriterGroupIds,
+ Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(FxResults(harness, nextOutputs)[0].DataSetWriterIds,
+ Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(FxResults(harness, nextOutputs)[0].DefaultPublisherId.TryGetValue(out ulong publisher),
+ Is.True);
+ Assert.That(publisher, Is.EqualTo(7UL));
+ }
+ policy.Verify(value => value.ResolveAsync(
+ Profiles.PubSubUdpUadpTransport, It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task FxLifecycleBindingsRemainIsolatedAcrossComponentsAndAwaitHostCompletionAsync()
+ {
+ await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ AutomationComponentState firstComponent = harness.CreateFxComponentWithCloseConnections();
+ AutomationComponentState secondComponent = harness.CreateFxComponentWithCloseConnections();
+ firstComponent.NodeId = new NodeId("component:first", 1);
+ firstComponent.EstablishConnections!.NodeId = new NodeId("component:first:establish", 1);
+ firstComponent.CloseConnections!.NodeId = new NodeId("component:first:close", 1);
+ secondComponent.NodeId = new NodeId("component:second", 1);
+ secondComponent.EstablishConnections!.NodeId = new NodeId("component:second:establish", 1);
+ secondComponent.CloseConnections!.NodeId = new NodeId("component:second:close", 1);
+ ServerSystemContext secondContext = new(harness.Context.Server)
+ {
+ SessionId = new NodeId(456u),
+ UserIdentity = harness.Context.UserIdentity
+ };
+ ArrayOf firstEndpoints =
+ [new NodeId("first:endpoint:a", 1), new NodeId("first:endpoint:b", 1)];
+ ArrayOf secondEndpoints =
+ [new NodeId("second:endpoint:b", 1), new NodeId("second:endpoint:a", 1)];
+ ArrayOf firstResults = [StatusCodes.Good, StatusCodes.BadNodeIdUnknown];
+ ArrayOf secondResults = [StatusCodes.BadInvalidState, StatusCodes.Good];
+ var firstCall = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondCall = new TaskCompletionSource(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var firstGate = new TaskCompletionSource>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var secondGate = new TaskCompletionSource>(
+ TaskCreationOptions.RunContinuationsAsynchronously);
+ var lifecycle = new Mock();
+ lifecycle.Setup(value => value.CloseConnectionsAsync(
+ It.IsAny(), It.IsAny(), It.IsAny>(),
+ It.IsAny(), It.IsAny()))
+ .Returns((ISystemContext context, AutomationComponentState component, ArrayOf endpoints,
+ bool remove, CancellationToken token) =>
+ {
+ var call = new CloseLifecycleCall(context, component, endpoints, remove, token);
+ if (ReferenceEquals(component, firstComponent))
+ {
+ firstCall.TrySetResult(call);
+ return AwaitLifecycleResultsAsync(firstGate.Task);
+ }
+ if (ReferenceEquals(component, secondComponent))
+ {
+ secondCall.TrySetResult(call);
+ return AwaitLifecycleResultsAsync(secondGate.Task);
+ }
+ throw new InvalidOperationException("The binder dispatched an unbound component.");
+ });
+ var binder = new PubSubFxReservationMethods(
+ harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object);
+ binder.Bind(firstComponent);
+ binder.Bind(secondComponent);
+ using var firstCancellation = new CancellationTokenSource();
+ using var secondCancellation = new CancellationTokenSource();
+ Task<(ServiceResult Result, List Outputs)> firstPending = harness.CallAsync(
+ firstComponent.CloseConnections!, harness.Context,
+ [Variant.From(firstEndpoints), Variant.From(false)], firstCancellation.Token);
+ Task<(ServiceResult Result, List Outputs)> secondPending = harness.CallAsync(
+ secondComponent.CloseConnections!, secondContext,
+ [Variant.From(secondEndpoints), Variant.From(true)], secondCancellation.Token);
+
+ try
+ {
+ CloseLifecycleCall[] calls = await Task.WhenAll(
+ firstCall.Task.WaitAsync(TimeSpan.FromSeconds(10)),
+ secondCall.Task.WaitAsync(TimeSpan.FromSeconds(10))).ConfigureAwait(false);
+ Assert.That(calls[0].Context, Is.SameAs(harness.Context));
+ Assert.That(calls[0].Component, Is.SameAs(firstComponent));
+ Assert.That(calls[0].Endpoints, Is.EqualTo(firstEndpoints));
+ Assert.That(calls[0].Remove, Is.False);
+ Assert.That(calls[0].Token, Is.EqualTo(firstCancellation.Token));
+ Assert.That(calls[1].Context, Is.SameAs(secondContext));
+ Assert.That(calls[1].Component, Is.SameAs(secondComponent));
+ Assert.That(calls[1].Endpoints, Is.EqualTo(secondEndpoints));
+ Assert.That(calls[1].Remove, Is.True);
+ Assert.That(calls[1].Token, Is.EqualTo(secondCancellation.Token));
+ Assert.That(firstPending.IsCompleted, Is.False);
+ Assert.That(secondPending.IsCompleted, Is.False);
+
+ secondGate.SetResult(secondResults);
+ (ServiceResult secondResult, List secondOutputs) =
+ await secondPending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ Assert.That(secondResult.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(secondOutputs, Has.Count.EqualTo(1));
+ Assert.That(secondOutputs[0].TryGetValue(out ArrayOf actualSecondResults), Is.True);
+ Assert.That(actualSecondResults, Is.EqualTo(secondResults));
+ Assert.That(firstPending.IsCompleted, Is.False);
+
+ firstGate.SetResult(firstResults);
+ (ServiceResult firstResult, List firstOutputs) =
+ await firstPending.WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ Assert.That(firstResult.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(firstOutputs, Has.Count.EqualTo(1));
+ Assert.That(firstOutputs[0].TryGetValue(out ArrayOf actualFirstResults), Is.True);
+ Assert.That(actualFirstResults, Is.EqualTo(firstResults));
+ lifecycle.Verify(value => value.CloseConnectionsAsync(
+ It.IsAny(), It.IsAny(),
+ It.IsAny>(), It.IsAny(), It.IsAny()),
+ Times.Exactly(2));
+ }
+ finally
+ {
+ firstGate.TrySetResult(firstResults);
+ secondGate.TrySetResult(secondResults);
+ await Task.WhenAll(firstPending, secondPending)
+ .WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
+ }
+ }
+
+ private static CloseConnectionsMethodState CreateMisparentedCloseMethod(Harness harness)
+ {
+ var otherComponent = new AutomationComponentState(null)
+ {
+ NodeId = new NodeId("other-component", 1)
+ };
+ var method = new CloseConnectionsMethodState(otherComponent);
+ method.Create(harness.Context, new NodeId("misparented-close", 1),
+ new QualifiedName("CloseConnections"), new LocalizedText("CloseConnections"), true);
+ return method;
+ }
+
+ [Test]
+ public async Task FxFunctionalEntityNotSetWithoutErrorsDoesNotAbortReservationAsync()
+ {
+ var policy = new Mock();
+ policy.Setup(value => value.ResolveAsync(
+ Profiles.PubSubUdpUadpTransport, It.IsAny()))
+ .Returns(new ValueTask(Variant.From(7UL)));
+ await using var harness = new Harness(policy.Object,
+ new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ EstablishConnectionsMethodState method = harness.BindFx();
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ NodeId functionalEntityId = new("not-set-without-errors-fe", namespaceIndex);
+ _ = harness.AddVerifiableFunctionalEntity(
+ functionalEntityId, FunctionalEntityVerificationResultEnum.NotSet,
+ verificationErrors: ArrayOf.Empty);
+ NodeIdValuePair expectedVariable = ExpectedVerificationVariable("expected", namespaceIndex);
+ ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
+ functionalEntityId, [expectedVariable]);
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, FxArguments(
+ (uint)FxCommandMask.VerifyFunctionalEntityCmd |
+ (uint)FxCommandMask.ReserveCommunicationIdsCmd,
+ ArrayOf.Empty, [endpoint], FxRequest())).ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(outputs, Has.Count.EqualTo(4));
+ Assert.That(harness.VerifyFunctionalEntityCalls, Is.EqualTo(1));
+ Assert.That(harness.LastVerifyFunctionalEntityInputs.Count, Is.EqualTo(1));
+ Assert.That(harness.LastVerifyFunctionalEntityInputs[0].TryGetValue(
+ out ArrayOf passedVariables, harness.Context.AsMessageContext()), Is.True);
+ Assert.That(passedVariables, Is.EqualTo(new[] { expectedVariable }));
+ Assert.That(FxAssetResults(harness, outputs), Is.Empty);
+ ConnectionEndpointConfigurationResultDataType[] endpointResults = FxEndpointResults(harness, outputs);
+ Assert.That(endpointResults, Has.Length.EqualTo(1));
+ Assert.That(endpointResults[0].FunctionalEntityNodeResult, Is.EqualTo(StatusCodes.Good));
+ Assert.That(endpointResults[0].ConnectionEndpointId, Is.EqualTo(endpoint.ConnectionEndpoint.Node));
+ Assert.That(endpointResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good));
+ Assert.That(endpointResults[0].VerificationResult,
+ Is.EqualTo(FunctionalEntityVerificationResultEnum.NotSet));
+ Assert.That(endpointResults[0].VerificationVariablesErrors.IsEmpty, Is.True);
+ PubSubReserveCommunicationIdsResult2DataType[] reservations = FxResults(harness, outputs);
+ Assert.That(reservations, Has.Length.EqualTo(1));
+ Assert.That(reservations[0].Result, Is.EqualTo(StatusCodes.Good));
+ Assert.That(reservations[0].WriterGroupIds, Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(reservations[0].DataSetWriterIds, Is.EqualTo(new ushort[] { 0x8000 }));
+ Assert.That(reservations[0].DefaultPublisherId.TryGetValue(out ulong publisher), Is.True);
+ Assert.That(publisher, Is.EqualTo(7UL));
+ policy.Verify(value => value.ResolveAsync(
+ Profiles.PubSubUdpUadpTransport, It.IsAny()), Times.Once);
+ }
+
+ private static void AssertNoMethodHandlers(MethodState method)
+ {
+ Assert.That(method.OnCallMethod, Is.Null);
+ Assert.That(method.OnCallMethod2, Is.Null);
+ Assert.That(method.OnCallMethodWithResultAsync, Is.Null);
+ Assert.That(method.OnCallMethod2Async, Is.Null);
+ if (method is EstablishConnectionsMethodState establish)
+ {
+ Assert.That(establish.OnCall, Is.Null);
+ Assert.That(establish.OnCallAsync, Is.Null);
+ }
+ if (method is CloseConnectionsMethodState close)
+ {
+ Assert.That(close.OnCall, Is.Null);
+ Assert.That(close.OnCallAsync, Is.Null);
+ }
+ }
+
+ private static async ValueTask> AwaitLifecycleResultsAsync(
+ Task> results)
+ {
+ return await results.ConfigureAwait(false);
+ }
+
+ private sealed record CloseLifecycleCall(
+ ISystemContext Context,
+ AutomationComponentState Component,
+ ArrayOf Endpoints,
+ bool Remove,
+ CancellationToken Token);
+
private static async ValueTask> WaitForCancellationAsync(
TaskCompletionSource entered,
CancellationToken cancellationToken)
@@ -2726,6 +3342,187 @@ namespace Opc.Ua.PubSub.Server.Tests
return [];
}
+ [Test]
+ public async Task FxEndpointLifecycleRunsAfterReservationsAndReturnsOrderedResults()
+ {
+ var events = new List();
+ var policy = new Mock();
+ policy.Setup(value => value.ResolveAsync(
+ Profiles.PubSubUdpUadpTransport, It.IsAny()))
+ .Returns((string _, CancellationToken _) =>
+ {
+ events.Add("reserve");
+ return new ValueTask(Variant.From(7UL));
+ });
+ await using var harness = new Harness(policy.Object,
+ new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ AutomationComponentState component = harness.CreateFxComponentWithCloseConnections();
+ EstablishConnectionsMethodState method = component.EstablishConnections!;
+ var lifecycle = new Mock();
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
+ new NodeId("lifecycle-functional-entity", namespaceIndex),
+ ArrayOf.Empty);
+ endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
+ ArrayOf endpoints = [endpoint];
+ uint commandMask = (uint)FxCommandMask.ReserveCommunicationIdsCmd |
+ (uint)FxCommandMask.SetConfigurationDataCmd;
+ var endpointResult = new ConnectionEndpointConfigurationResultDataType
+ {
+ ConnectionEndpointId = endpoint.ConnectionEndpoint.Node,
+ FunctionalEntityNodeResult = StatusCodes.Good,
+ ConnectionEndpointResult = StatusCodes.Good,
+ VerificationResult = FunctionalEntityVerificationResultEnum.NotSet,
+ VerificationStatus = StatusCodes.Good,
+ VerificationVariablesErrors = [],
+ EstablishControlResult = [],
+ ConfigurationDataResult = [StatusCodes.Good],
+ ReassignControlResult = [],
+ CommunicationLinksResult = StatusCodes.Good,
+ EnableCommunicationResult = StatusCodes.Good
+ };
+ lifecycle.Setup(value => value.EstablishConnectionsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ commandMask,
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns((ISystemContext context, AutomationComponentState actualComponent, uint actualCommandMask,
+ ArrayOf actualEndpoints,
+ ArrayOf initialResults,
+ ArrayOf reservationResults,
+ ArrayOf actualConfigurations,
+ CancellationToken cancellationToken) =>
+ {
+ events.Add("lifecycle");
+ Assert.That(context, Is.SameAs(harness.Context));
+ Assert.That(actualComponent, Is.SameAs(component));
+ Assert.That(actualCommandMask, Is.EqualTo(commandMask));
+ Assert.That(actualEndpoints, Is.EqualTo(endpoints));
+ Assert.That(initialResults.Count, Is.EqualTo(1));
+ Assert.That(initialResults[0].VerificationStatus, Is.EqualTo(StatusCodes.Good));
+ Assert.That(reservationResults.Count, Is.EqualTo(1));
+ Assert.That(reservationResults[0],
+ Is.InstanceOf());
+ var reservation =
+ (PubSubReserveCommunicationIdsResult2DataType)reservationResults[0];
+ Assert.That(reservation.Result, Is.EqualTo(StatusCodes.Good));
+ Assert.That(reservation.WriterGroupIds.Count, Is.EqualTo(1));
+ Assert.That(reservation.WriterGroupIds[0], Is.EqualTo((ushort)0x8000));
+ Assert.That(actualConfigurations, Is.Empty);
+ Assert.That(cancellationToken, Is.EqualTo(CancellationToken.None));
+ return new ValueTask(
+ new PubSubFxEstablishConnectionsResult(
+ ServiceResult.Good,
+ [endpointResult],
+ ArrayOf.Empty));
+ });
+ new PubSubFxReservationMethods(
+ harness.Application,
+ new PubSubServerOptions { ExposeFxModel = true },
+ lifecycle.Object).Bind(component);
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, FxArguments(
+ commandMask,
+ ArrayOf.Empty,
+ endpoints,
+ FxRequest())).ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
+ Assert.That(string.Join(",", events), Is.EqualTo("reserve,lifecycle"));
+ Assert.That(outputs, Has.Count.EqualTo(4));
+ ConnectionEndpointConfigurationResultDataType[] endpointResults =
+ FxEndpointResults(harness, outputs);
+ Assert.That(endpointResults, Has.Length.EqualTo(1));
+ Assert.That(endpointResults[0].ConnectionEndpointId, Is.EqualTo(endpointResult.ConnectionEndpointId));
+ Assert.That(endpointResults[0].ConfigurationDataResult, Is.EqualTo(new[] { StatusCodes.Good }));
+ Assert.That(FxResults(harness, outputs)[0].Result, Is.EqualTo(StatusCodes.Good));
+ Assert.That(outputs[3].TryGetValue(
+ out ArrayOf configurations,
+ harness.Context.AsMessageContext()), Is.True);
+ Assert.That(configurations, Is.Empty);
+ lifecycle.Verify(value => value.EstablishConnectionsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ commandMask,
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny()), Times.Once);
+ }
+
+ [Test]
+ public async Task FxEndpointLifecycleWithoutHostCapabilityFailsClosed()
+ {
+ await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ EstablishConnectionsMethodState method = harness.BindFx();
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
+ new NodeId("lifecycle-functional-entity", namespaceIndex),
+ ArrayOf.Empty);
+ endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
+ uint commandMask = (uint)FxCommandMask.SetConfigurationDataCmd;
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, FxArguments(
+ commandMask,
+ ArrayOf.Empty,
+ [endpoint])).ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
+ Assert.That(outputs, Is.Empty);
+ }
+
+ [Test]
+ public async Task FxEndpointLifecycleRejectsMalformedHostResultShape()
+ {
+ await using var harness = new Harness(options: new PubSubServerOptions { ExposeFxModel = true });
+ await harness.InitializeAsync().ConfigureAwait(false);
+ AutomationComponentState component = harness.CreateFxComponentWithCloseConnections();
+ EstablishConnectionsMethodState method = component.EstablishConnections!;
+ var lifecycle = new Mock();
+ uint commandMask = (uint)FxCommandMask.SetConfigurationDataCmd;
+ ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
+ ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
+ new NodeId("lifecycle-functional-entity", namespaceIndex),
+ ArrayOf.Empty);
+ endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
+ lifecycle.Setup(value => value.EstablishConnectionsAsync(
+ It.IsAny(),
+ It.IsAny(),
+ commandMask,
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny>(),
+ It.IsAny()))
+ .Returns(new ValueTask(
+ new PubSubFxEstablishConnectionsResult(
+ ServiceResult.Good,
+ ArrayOf.Empty,
+ ArrayOf.Empty)));
+ new PubSubFxReservationMethods(
+ harness.Application,
+ new PubSubServerOptions { ExposeFxModel = true },
+ lifecycle.Object).Bind(component);
+
+ (ServiceResult result, List outputs) = await harness.CallAsync(
+ method, harness.Context, FxArguments(
+ commandMask,
+ ArrayOf.Empty,
+ [endpoint])).ConfigureAwait(false);
+
+ Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
+ Assert.That(outputs, Is.Empty);
+ }
+
private static PubSubReserveCommunicationIds2DataType FxRequest()
{
return new PubSubReserveCommunicationIds2DataType