mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-28 05:57:47 +08:00
Add host-dispatched UAFX endpoint lifecycle
This commit is contained in:
+40
-28
@@ -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`.
|
||||
|
||||
@@ -51,8 +51,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers injectable Part 81 reservation methods for host-owned AutomationComponents.
|
||||
/// A host lifecycle capability registered with <see cref="WithFxConnectionLifecycle"/> is also used
|
||||
/// to bind the component's <c>CloseConnections</c> Method.
|
||||
/// A host lifecycle capability registered with <see cref="WithFxConnectionLifecycle"/> handles
|
||||
/// endpoint lifecycle commands and the component's <c>CloseConnections</c> Method.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
|
||||
@@ -70,7 +70,8 @@ namespace Microsoft.Extensions.DependencyInjection
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers the host-owned, list-level lifecycle transaction used by Part 81 <c>CloseConnections</c>.
|
||||
/// Registers the host-owned lifecycle capability used by Part 81 endpoint commands and
|
||||
/// <c>CloseConnections</c>.
|
||||
/// </summary>
|
||||
/// <param name="builder">PubSub server builder.</param>
|
||||
/// <param name="lifecycle">Host implementation that authorizes and transacts endpoint lifecycle.</param>
|
||||
|
||||
@@ -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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
public interface IPubSubFxConnectionLifecycle
|
||||
{
|
||||
/// <summary>
|
||||
/// Processes the host-owned endpoint commands in an EstablishConnections call.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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.
|
||||
/// </remarks>
|
||||
/// <param name="context">The actual Method call context, including its authenticated Session.</param>
|
||||
/// <param name="component">The host-owned AutomationComponent whose Method was called.</param>
|
||||
/// <param name="commandMask">The complete EstablishConnections command mask.</param>
|
||||
/// <param name="connectionEndpointConfigurations">The ordered endpoint configuration requests.</param>
|
||||
/// <param name="initialEndpointResults">
|
||||
/// Ordered results produced by earlier verification commands in the same Method call.
|
||||
/// </param>
|
||||
/// <param name="reservationResults">
|
||||
/// Ordered communication-ID reservations completed earlier in the same Method call.
|
||||
/// </param>
|
||||
/// <param name="communicationConfigurations">
|
||||
/// Communication-model configuration supplied for SetCommunicationConfigurationCmd.
|
||||
/// </param>
|
||||
/// <param name="cancellationToken">Cancels processing where the host can safely honor cancellation.</param>
|
||||
/// <returns>Overall status and the ordered endpoint and communication-configuration results.</returns>
|
||||
ValueTask<PubSubFxEstablishConnectionsResult> EstablishConnectionsAsync(
|
||||
ISystemContext context,
|
||||
AutomationComponentState component,
|
||||
uint commandMask,
|
||||
ArrayOf<ConnectionEndpointConfigurationDataType> connectionEndpointConfigurations,
|
||||
ArrayOf<ConnectionEndpointConfigurationResultDataType> initialEndpointResults,
|
||||
ArrayOf<ReserveCommunicationIdsResultDataType> reservationResults,
|
||||
ArrayOf<CommunicationConfigurationDataType> communicationConfigurations,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Closes or removes the specified host-owned ConnectionEndpoints in one lifecycle transaction.
|
||||
/// </summary>
|
||||
@@ -62,4 +98,45 @@ namespace Opc.Ua.PubSub.Server
|
||||
bool remove,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Contains the result of host-owned Part 81 EstablishConnections endpoint commands.
|
||||
/// </summary>
|
||||
public sealed class PubSubFxEstablishConnectionsResult
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a result containing the overall method status and ordered command result arrays.
|
||||
/// </summary>
|
||||
/// <param name="serviceResult">The overall EstablishConnections status.</param>
|
||||
/// <param name="connectionEndpointConfigurationResults">
|
||||
/// Per-endpoint results in the request order.
|
||||
/// </param>
|
||||
/// <param name="communicationConfigurationResults">
|
||||
/// Communication-configuration result array; one item when the command is processed.
|
||||
/// </param>
|
||||
public PubSubFxEstablishConnectionsResult(
|
||||
ServiceResult serviceResult,
|
||||
ArrayOf<ConnectionEndpointConfigurationResultDataType> connectionEndpointConfigurationResults,
|
||||
ArrayOf<CommunicationConfigurationResultDataType> communicationConfigurationResults)
|
||||
{
|
||||
ServiceResult = serviceResult;
|
||||
ConnectionEndpointConfigurationResults = connectionEndpointConfigurationResults;
|
||||
CommunicationConfigurationResults = communicationConfigurationResults;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the overall EstablishConnections status.
|
||||
/// </summary>
|
||||
public ServiceResult ServiceResult { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the per-endpoint results in request order.
|
||||
/// </summary>
|
||||
public ArrayOf<ConnectionEndpointConfigurationResultDataType> ConnectionEndpointConfigurationResults { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the communication-configuration result array.
|
||||
/// </summary>
|
||||
public ArrayOf<CommunicationConfigurationResultDataType> CommunicationConfigurationResults { get; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,10 +41,8 @@ using Opc.Ua.Server;
|
||||
namespace Opc.Ua.PubSub.Server
|
||||
{
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[global::System.Diagnostics.CodeAnalysis.Experimental(DataChannelFeature.ExperimentalDiagnosticId,
|
||||
UrlFormat = DataChannelFeature.ExperimentalUrlFormat)]
|
||||
@@ -64,7 +62,8 @@ namespace Opc.Ua.PubSub.Server
|
||||
/// <param name="application">The PubSub application supporting communication-ID reservations.</param>
|
||||
/// <param name="options">The PubSub server options governing configuration method authorization.</param>
|
||||
/// <param name="connectionLifecycle">
|
||||
/// Optional host capability that atomically closes or removes ConnectionEndpoints.
|
||||
/// Optional host capability that processes endpoint lifecycle commands and closes or removes
|
||||
/// ConnectionEndpoints.
|
||||
/// </param>
|
||||
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<ReserveCommunicationIdsDataType> reservations,
|
||||
ArrayOf<CommunicationConfigurationDataType> configurations,
|
||||
Func<CancellationToken, ValueTask<string?>>? 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<NodeIdValuePair> 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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the existing endpoint NodeId when the union selects its Node member.
|
||||
/// </summary>
|
||||
private static NodeId GetConnectionEndpointNodeId(ConnectionEndpointConfigurationDataType endpoint)
|
||||
{
|
||||
return endpoint.ConnectionEndpoint.SwitchField == ConnectionEndpointDefinitionDataTypeFields.Node
|
||||
? endpoint.ConnectionEndpoint.Node
|
||||
: NodeId.Null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Confirms the FunctionalEntity is hosted by an Asset in the bound AutomationComponent.
|
||||
/// </summary>
|
||||
|
||||
@@ -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<IPubSubFxConnectionLifecycle>();
|
||||
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<PubSubFxReservationMethods>();
|
||||
PubSubServerOptions registeredOptions =
|
||||
provider.GetRequiredService<IOptions<PubSubServerOptions>>().Value;
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(provider.GetRequiredService<IPubSubFxConnectionLifecycle>(), 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<NodeId> endpoints =
|
||||
[new NodeId("endpoint:z", 1), new NodeId("endpoint:a", 1), new NodeId("endpoint:z", 1)];
|
||||
ArrayOf<StatusCode> expected =
|
||||
[StatusCodes.BadInvalidState, StatusCodes.Good, StatusCodes.BadNodeIdUnknown];
|
||||
const bool remove = true;
|
||||
binder.Bind(component);
|
||||
|
||||
lifecycle.Setup(value => value.CloseConnectionsAsync(
|
||||
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
|
||||
It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((ISystemContext context, AutomationComponentState actualComponent,
|
||||
ArrayOf<NodeId> 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<ArrayOf<StatusCode>>(expected);
|
||||
});
|
||||
|
||||
(ServiceResult result, List<Variant> 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<StatusCode> actualResults), Is.True);
|
||||
Assert.That(actualResults, Is.EqualTo(expected));
|
||||
lifecycle.Verify(value => value.CloseConnectionsAsync(
|
||||
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
|
||||
It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
internal static ServiceCollection BuildServicesWithRuntime()
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
|
||||
@@ -2717,6 +2717,622 @@ namespace Opc.Ua.PubSub.Server.Tests
|
||||
It.IsAny<bool>(), It.IsAny<CancellationToken>()), 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<IPubSubDefaultPublisherIdPolicy>();
|
||||
policy.Setup(value => value.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string _, CancellationToken _) => new ValueTask<Variant>(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<string?>("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<Variant> 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<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
|
||||
(ServiceResult next, List<Variant> 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<IPubSubFxConnectionLifecycle>();
|
||||
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<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
|
||||
It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FxVerificationRejectsNullSelectedEndpointNodeBeforeCallingHostAsync()
|
||||
{
|
||||
var policy = new Mock<IPubSubDefaultPublisherIdPolicy>();
|
||||
policy.Setup(value => value.ResolveAsync(It.IsAny<string>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((string _, CancellationToken _) => new ValueTask<Variant>(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<Variant> outputs) = await harness.CallAsync(
|
||||
method, harness.Context, FxArguments(
|
||||
(uint)FxCommandMask.VerifyFunctionalEntityCmd |
|
||||
(uint)FxCommandMask.ReserveCommunicationIdsCmd,
|
||||
ArrayOf<AssetVerificationDataType>.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<string>(), It.IsAny<CancellationToken>()),
|
||||
Times.Never);
|
||||
(ServiceResult next, List<Variant> 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<IPubSubFxConnectionLifecycle>();
|
||||
new PubSubFxReservationMethods(
|
||||
harness.Application, new PubSubServerOptions { ExposeFxModel = true }, lifecycle.Object)
|
||||
.Bind(component);
|
||||
ArrayOf<NodeId> endpoints = [new NodeId("dispatch-endpoint", 4)];
|
||||
ArrayOf<Variant> 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<ServiceResult>();
|
||||
var outputs = new List<Variant>();
|
||||
|
||||
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<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
|
||||
It.IsAny<bool>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FxAllSupportedCommandsExecuteInStageOrderAsync()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var assetEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var functionalEntityEntered =
|
||||
new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var reservationEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var assetGate = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var functionalEntityGate = new TaskCompletionSource<bool>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var policy = new Mock<IPubSubDefaultPublisherIdPolicy>();
|
||||
policy.Setup(value => value.ResolveAsync(
|
||||
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
|
||||
.Returns((string _, CancellationToken _) =>
|
||||
{
|
||||
events.Add("reservation");
|
||||
reservationEntered.TrySetResult(true);
|
||||
return new ValueTask<Variant>(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<StatusCode>.Empty);
|
||||
outputs[2] = Variant.From(ArrayOf<StatusCode>.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<StatusCode>.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<Variant> 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<Variant> 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<CommunicationConfigurationResultDataType> 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<IPubSubDefaultPublisherIdPolicy>();
|
||||
policy.Setup(value => value.ResolveAsync(
|
||||
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<Variant>(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<Variant> 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<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
(ServiceResult next, List<Variant> 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<CancellationToken>()), 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<NodeId> firstEndpoints =
|
||||
[new NodeId("first:endpoint:a", 1), new NodeId("first:endpoint:b", 1)];
|
||||
ArrayOf<NodeId> secondEndpoints =
|
||||
[new NodeId("second:endpoint:b", 1), new NodeId("second:endpoint:a", 1)];
|
||||
ArrayOf<StatusCode> firstResults = [StatusCodes.Good, StatusCodes.BadNodeIdUnknown];
|
||||
ArrayOf<StatusCode> secondResults = [StatusCodes.BadInvalidState, StatusCodes.Good];
|
||||
var firstCall = new TaskCompletionSource<CloseLifecycleCall>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var secondCall = new TaskCompletionSource<CloseLifecycleCall>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var firstGate = new TaskCompletionSource<ArrayOf<StatusCode>>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var secondGate = new TaskCompletionSource<ArrayOf<StatusCode>>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var lifecycle = new Mock<IPubSubFxConnectionLifecycle>();
|
||||
lifecycle.Setup(value => value.CloseConnectionsAsync(
|
||||
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(), It.IsAny<ArrayOf<NodeId>>(),
|
||||
It.IsAny<bool>(), It.IsAny<CancellationToken>()))
|
||||
.Returns((ISystemContext context, AutomationComponentState component, ArrayOf<NodeId> 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<Variant> Outputs)> firstPending = harness.CallAsync(
|
||||
firstComponent.CloseConnections!, harness.Context,
|
||||
[Variant.From(firstEndpoints), Variant.From(false)], firstCancellation.Token);
|
||||
Task<(ServiceResult Result, List<Variant> 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<Variant> 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<StatusCode> actualSecondResults), Is.True);
|
||||
Assert.That(actualSecondResults, Is.EqualTo(secondResults));
|
||||
Assert.That(firstPending.IsCompleted, Is.False);
|
||||
|
||||
firstGate.SetResult(firstResults);
|
||||
(ServiceResult firstResult, List<Variant> 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<StatusCode> actualFirstResults), Is.True);
|
||||
Assert.That(actualFirstResults, Is.EqualTo(firstResults));
|
||||
lifecycle.Verify(value => value.CloseConnectionsAsync(
|
||||
It.IsAny<ISystemContext>(), It.IsAny<AutomationComponentState>(),
|
||||
It.IsAny<ArrayOf<NodeId>>(), It.IsAny<bool>(), It.IsAny<CancellationToken>()),
|
||||
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<IPubSubDefaultPublisherIdPolicy>();
|
||||
policy.Setup(value => value.ResolveAsync(
|
||||
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<Variant>(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<StatusCode>.Empty);
|
||||
NodeIdValuePair expectedVariable = ExpectedVerificationVariable("expected", namespaceIndex);
|
||||
ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
|
||||
functionalEntityId, [expectedVariable]);
|
||||
|
||||
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
|
||||
method, harness.Context, FxArguments(
|
||||
(uint)FxCommandMask.VerifyFunctionalEntityCmd |
|
||||
(uint)FxCommandMask.ReserveCommunicationIdsCmd,
|
||||
ArrayOf<AssetVerificationDataType>.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<NodeIdValuePair> 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<CancellationToken>()), 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<ArrayOf<StatusCode>> AwaitLifecycleResultsAsync(
|
||||
Task<ArrayOf<StatusCode>> results)
|
||||
{
|
||||
return await results.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private sealed record CloseLifecycleCall(
|
||||
ISystemContext Context,
|
||||
AutomationComponentState Component,
|
||||
ArrayOf<NodeId> Endpoints,
|
||||
bool Remove,
|
||||
CancellationToken Token);
|
||||
|
||||
private static async ValueTask<ArrayOf<StatusCode>> WaitForCancellationAsync(
|
||||
TaskCompletionSource<bool> entered,
|
||||
CancellationToken cancellationToken)
|
||||
@@ -2726,6 +3342,187 @@ namespace Opc.Ua.PubSub.Server.Tests
|
||||
return [];
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task FxEndpointLifecycleRunsAfterReservationsAndReturnsOrderedResults()
|
||||
{
|
||||
var events = new List<string>();
|
||||
var policy = new Mock<IPubSubDefaultPublisherIdPolicy>();
|
||||
policy.Setup(value => value.ResolveAsync(
|
||||
Profiles.PubSubUdpUadpTransport, It.IsAny<CancellationToken>()))
|
||||
.Returns((string _, CancellationToken _) =>
|
||||
{
|
||||
events.Add("reserve");
|
||||
return new ValueTask<Variant>(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<IPubSubFxConnectionLifecycle>();
|
||||
ushort namespaceIndex = (ushort)harness.Namespaces.GetIndex(Opc.Ua.Fx.Ac.Namespaces.FxAc);
|
||||
ConnectionEndpointConfigurationDataType endpoint = FxEndpoint(
|
||||
new NodeId("lifecycle-functional-entity", namespaceIndex),
|
||||
ArrayOf<NodeIdValuePair>.Empty);
|
||||
endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
|
||||
ArrayOf<ConnectionEndpointConfigurationDataType> 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<ISystemContext>(),
|
||||
It.IsAny<AutomationComponentState>(),
|
||||
commandMask,
|
||||
It.IsAny<ArrayOf<ConnectionEndpointConfigurationDataType>>(),
|
||||
It.IsAny<ArrayOf<ConnectionEndpointConfigurationResultDataType>>(),
|
||||
It.IsAny<ArrayOf<ReserveCommunicationIdsResultDataType>>(),
|
||||
It.IsAny<ArrayOf<CommunicationConfigurationDataType>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns((ISystemContext context, AutomationComponentState actualComponent, uint actualCommandMask,
|
||||
ArrayOf<ConnectionEndpointConfigurationDataType> actualEndpoints,
|
||||
ArrayOf<ConnectionEndpointConfigurationResultDataType> initialResults,
|
||||
ArrayOf<ReserveCommunicationIdsResultDataType> reservationResults,
|
||||
ArrayOf<CommunicationConfigurationDataType> 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<PubSubReserveCommunicationIdsResult2DataType>());
|
||||
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<PubSubFxEstablishConnectionsResult>(
|
||||
new PubSubFxEstablishConnectionsResult(
|
||||
ServiceResult.Good,
|
||||
[endpointResult],
|
||||
ArrayOf<CommunicationConfigurationResultDataType>.Empty));
|
||||
});
|
||||
new PubSubFxReservationMethods(
|
||||
harness.Application,
|
||||
new PubSubServerOptions { ExposeFxModel = true },
|
||||
lifecycle.Object).Bind(component);
|
||||
|
||||
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
|
||||
method, harness.Context, FxArguments(
|
||||
commandMask,
|
||||
ArrayOf<AssetVerificationDataType>.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<CommunicationConfigurationResultDataType> configurations,
|
||||
harness.Context.AsMessageContext()), Is.True);
|
||||
Assert.That(configurations, Is.Empty);
|
||||
lifecycle.Verify(value => value.EstablishConnectionsAsync(
|
||||
It.IsAny<ISystemContext>(),
|
||||
It.IsAny<AutomationComponentState>(),
|
||||
commandMask,
|
||||
It.IsAny<ArrayOf<ConnectionEndpointConfigurationDataType>>(),
|
||||
It.IsAny<ArrayOf<ConnectionEndpointConfigurationResultDataType>>(),
|
||||
It.IsAny<ArrayOf<ReserveCommunicationIdsResultDataType>>(),
|
||||
It.IsAny<ArrayOf<CommunicationConfigurationDataType>>(),
|
||||
It.IsAny<CancellationToken>()), 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<NodeIdValuePair>.Empty);
|
||||
endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
|
||||
uint commandMask = (uint)FxCommandMask.SetConfigurationDataCmd;
|
||||
|
||||
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
|
||||
method, harness.Context, FxArguments(
|
||||
commandMask,
|
||||
ArrayOf<AssetVerificationDataType>.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<IPubSubFxConnectionLifecycle>();
|
||||
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<NodeIdValuePair>.Empty);
|
||||
endpoint.ConfigurationData = [ExpectedVerificationVariable("configuration", namespaceIndex)];
|
||||
lifecycle.Setup(value => value.EstablishConnectionsAsync(
|
||||
It.IsAny<ISystemContext>(),
|
||||
It.IsAny<AutomationComponentState>(),
|
||||
commandMask,
|
||||
It.IsAny<ArrayOf<ConnectionEndpointConfigurationDataType>>(),
|
||||
It.IsAny<ArrayOf<ConnectionEndpointConfigurationResultDataType>>(),
|
||||
It.IsAny<ArrayOf<ReserveCommunicationIdsResultDataType>>(),
|
||||
It.IsAny<ArrayOf<CommunicationConfigurationDataType>>(),
|
||||
It.IsAny<CancellationToken>()))
|
||||
.Returns(new ValueTask<PubSubFxEstablishConnectionsResult>(
|
||||
new PubSubFxEstablishConnectionsResult(
|
||||
ServiceResult.Good,
|
||||
ArrayOf<ConnectionEndpointConfigurationResultDataType>.Empty,
|
||||
ArrayOf<CommunicationConfigurationResultDataType>.Empty)));
|
||||
new PubSubFxReservationMethods(
|
||||
harness.Application,
|
||||
new PubSubServerOptions { ExposeFxModel = true },
|
||||
lifecycle.Object).Bind(component);
|
||||
|
||||
(ServiceResult result, List<Variant> outputs) = await harness.CallAsync(
|
||||
method, harness.Context, FxArguments(
|
||||
commandMask,
|
||||
ArrayOf<AssetVerificationDataType>.Empty,
|
||||
[endpoint])).ConfigureAwait(false);
|
||||
|
||||
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadUnexpectedError));
|
||||
Assert.That(outputs, Is.Empty);
|
||||
}
|
||||
|
||||
private static PubSubReserveCommunicationIds2DataType FxRequest()
|
||||
{
|
||||
return new PubSubReserveCommunicationIds2DataType
|
||||
|
||||
Reference in New Issue
Block a user