From 8d8712948e31ae76779a0e0ecb6815646270a577 Mon Sep 17 00:00:00 2001 From: Marc Schier Date: Mon, 19 Aug 2024 08:56:42 +0200 Subject: [PATCH] API to configure object or object types as writer (#2320) * Fix file system bug on linux * Update dependencies * Documentation updates --- .editorconfig | 5 + .github/workflows/dotnet.yml | 19 +- common.props | 2 +- docs/opc-publisher/api.md | 84 +- docs/opc-publisher/commandline.md | 2 +- docs/opc-publisher/definitions.md | 60 +- docs/opc-publisher/features.md | 40 +- docs/opc-publisher/openapi.json | 197 ++- .../IIoTPlatform-E2E-Tests.csproj | 6 +- .../OpcPublisher-AE-E2E-Tests.csproj | 7 +- samples/Netcap/src/Netcap.csproj | 6 +- .../Azure.IIoT.OpcUa.Publisher.Models.csproj | 3 +- .../src/PublishedNodeExpansionModel.cs | 125 ++ .../src/PublishedNodesEntryRequestModel.cs | 33 + ...e.IIoT.OpcUa.Publisher.Models.Tests.csproj | 6 +- ...ure.IIoT.OpcUa.Publisher.Module.Cli.csproj | 4 +- .../Azure.IIoT.OpcUa.Publisher.Module.csproj | 15 +- .../Controllers/ConfigurationController.cs | 4 +- .../src/Controllers/LegacyController.cs | 6 + .../src/Controllers/WriterController.cs | 109 +- .../src/Startup.cs | 2 + ...e.IIoT.OpcUa.Publisher.Module.Tests.csproj | 2 +- .../ConfigurationServicesRestClient.cs | 91 ++ .../DmApiPublisherControllerTests.cs | 8 +- .../Controller/TestData/Json/ExpandTests1.cs | 178 +++ .../TestData/MsgPack/ExpandTests1.cs | 178 +++ .../tests/Fixtures/PublisherModule.cs | 2 + .../tests/Fixtures/PublisherModuleFixture.cs | 1 - .../MqttConfigurationIntegrationTests.cs | 6 +- .../MqttPubSubIntegrationTests.cs | 9 +- .../MqttUnifiedNamespaceTests.cs | 4 +- .../AdvancedPubSubIntegrationTests.cs | 7 +- .../BasicPubSubIntegrationTests.cs | 12 +- .../BasicSamplesIntegrationTests.cs | 10 +- .../src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj | 6 +- ...re.IIoT.OpcUa.Publisher.Service.Cli.csproj | 3 +- .../cli/Program.cs | 2 +- ...re.IIoT.OpcUa.Publisher.Service.Sdk.csproj | 14 +- ...IIoT.OpcUa.Publisher.Service.WebApi.csproj | 15 +- .../src/Startup.cs | 8 +- ...pcUa.Publisher.Service.WebApi.Tests.csproj | 6 +- .../Azure.IIoT.OpcUa.Publisher.Service.csproj | 2 +- .../src/Clients/DiscoveryServicesClient.cs | 8 +- ...IoT.OpcUa.Publisher.Testing.Servers.csproj | 2 +- .../src/FileSystem/DirectoryObjectState.cs | 5 +- .../src/FileSystem/FileObjectState.cs | 2 +- .../src/FileSystem/FileSystemNodeManager.cs | 8 +- .../Azure.IIoT.OpcUa.Publisher.Testing.csproj | 6 +- .../tests/Fixtures/BaseServerFixture.cs | 2 +- .../tests/Tests/FileSystem/BrowseTests.cs | 14 +- .../tests/Tests/FileSystem/OperationsTests.cs | 1 - .../tests/Tests/TestData/BrowseStreamTests.cs | 27 +- .../Tests/TestData/ConfigurationTests1.cs | 691 ++++++++++ .../Tests/TestData/ConfigurationTests2.cs | 870 +++++++++++++ .../src/Azure.IIoT.OpcUa.Publisher.csproj | 4 +- .../src/Discovery/NetworkDiscovery.cs | 2 +- .../src/Discovery/ProgressPublisher.cs | 2 +- .../src/Extensions/ContainerBuilderEx.cs | 6 +- .../src/Extensions/RequestHeaderModelEx.cs | 74 ++ .../src/IConfigurationServices.cs | 174 +-- .../src/INodeServicesInternal.cs | 7 - .../src/IPublishedNodesServices.cs | 184 +++ .../src/Services/AsyncEnumerableBrowser.cs | 432 +++++++ .../src/Services/ConfigurationServices.cs | 761 +++++++++++ .../src/Services/Extensions.cs | 107 ++ .../src/Services/FileSystemServices.cs | 894 +++++++++++++ .../src/Services/HistoryServices.cs | 10 +- .../src/Services/NodeServices.cs | 1145 ++--------------- ...rvice.cs => PublishedNodesJsonServices.cs} | 12 +- .../src/Services/PublisherService.cs | 3 +- .../src/Stack/AsyncEnumerableBase.cs | 56 + .../src/Stack/AsyncEnumerableStack.cs | 104 ++ .../src/Stack/Extensions/ServiceResultEx.cs | 19 +- .../src/Stack/Extensions/SessionEx.cs | 46 +- .../src/Stack/IOpcUaClientManager.cs | 6 +- .../src/Stack/Services/OpcUaClient.Browser.cs | 2 +- .../src/Stack/Services/OpcUaClient.cs | 23 +- .../src/Stack/Services/OpcUaClientManager.cs | 22 +- .../Services/OpcUaMonitoredItem.Condition.cs | 2 +- .../Services/OpcUaMonitoredItem.Heartbeat.cs | 2 +- .../src/Stack/Services/OpcUaSession.cs | 4 +- .../src/Storage/PublishedNodesConverter.cs | 40 +- .../tests/Services/FileSystem/BrowseTests.cs | 8 +- .../Services/FileSystem/OperationsTests.cs | 8 +- .../tests/Services/FileSystem/ReadTests.cs | 8 +- .../tests/Services/FileSystem/WriteTests.cs | 8 +- .../HistoricalAccess/ReadAtTimesTests.cs | 5 +- .../HistoricalAccess/ReadModifiedTests.cs | 5 +- .../HistoricalAccess/ReadProcessedTests.cs | 5 +- .../HistoricalAccess/ReadValuesTests.cs | 5 +- .../HistoricalAccess/UpdateValuesTests.cs | 5 +- ....cs => PublishedNodesJsonServicesTests.cs} | 14 +- .../tests/Services/TestData/ExpandTests1.cs | 174 +++ .../tests/Services/TestData/ExpandTests2.cs | 192 +++ .../src/Azure.IIoT.OpcUa.csproj | 4 +- .../Extensions/PublishedNodesEntryModelEx.cs | 70 +- .../tests/Azure.IIoT.OpcUa.Tests.csproj | 4 +- 97 files changed, 6093 insertions(+), 1500 deletions(-) create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodeExpansionModel.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryRequestModel.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/ConfigurationServicesRestClient.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/Json/ExpandTests1.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/MsgPack/ExpandTests1.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests1.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests2.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Extensions/RequestHeaderModelEx.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/IPublishedNodesServices.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Services/AsyncEnumerableBrowser.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Services/ConfigurationServices.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Services/Extensions.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Services/FileSystemServices.cs rename src/Azure.IIoT.OpcUa.Publisher/src/Services/{PublisherConfigurationService.cs => PublishedNodesJsonServices.cs} (99%) create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableBase.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableStack.cs rename src/Azure.IIoT.OpcUa.Publisher/tests/Services/{PublisherConfigServicesTests.cs => PublishedNodesJsonServicesTests.cs} (99%) create mode 100644 src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests1.cs create mode 100644 src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests2.cs diff --git a/.editorconfig b/.editorconfig index 48b2618344..4b5d3660e0 100644 --- a/.editorconfig +++ b/.editorconfig @@ -489,6 +489,7 @@ csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent dotnet_diagnostic.RCS1055.severity = suggestion dotnet_diagnostic.RCS1039.severity = suggestion +csharp_prefer_static_anonymous_function = true:suggestion [*.{cs,vb}] dotnet_style_coalesce_expression = true:silent @@ -500,6 +501,8 @@ tab_width = 4 indent_size = 4 end_of_line = crlf dotnet_style_object_initializer = true:suggestion +dotnet_style_namespace_match_folder = false:silent + dotnet_diagnostic.CA2007.severity = warning dotnet_diagnostic.CA1848.severity = silent dotnet_diagnostic.CA1036.severity = suggestion @@ -524,6 +527,8 @@ dotnet_style_prefer_conditional_expression_over_assignment = true:silent # IDE0270: Use coalesce expression dotnet_diagnostic.IDE0270.severity = silent +# IDE0130: Namespace does not correspond to file location +dotnet_diagnostic.IDE0130.severity = silent dotnet_diagnostic.CA1031.severity = silent dotnet_style_prefer_conditional_expression_over_return = true:silent dotnet_style_explicit_tuple_names = true:suggestion diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index 67988c9645..e1cb2df4d0 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -1,33 +1,26 @@ name: Build Solutions - on: push: - branches: - - main + branches: [ "main" ] pull_request: - branches: - - main - + branches: [ "main" ] jobs: build: runs-on: ubuntu-latest - strategy: matrix: - solution: + solution: - '**/*.sln' - steps: - name: Checkout repository uses: actions/checkout@v2 - + with: + fetch-depth: 0 - name: Setup .NET uses: actions/setup-dotnet@v3 with: - dotnet-version: '8.x' - + dotnet-version: 8.0.x - name: Restore dependencies run: dotnet restore ${{ matrix.solution }} - - name: Build run: dotnet build ${{ matrix.solution }} --configuration Release --no-restore diff --git a/common.props b/common.props index 737c9f1853..cda59b01e9 100644 --- a/common.props +++ b/common.props @@ -34,7 +34,7 @@ - + true diff --git a/docs/opc-publisher/api.md b/docs/opc-publisher/api.md index 6447028357..5062c55001 100644 --- a/docs/opc-publisher/api.md +++ b/docs/opc-publisher/api.md @@ -3329,6 +3329,47 @@ This section contains the API to configure data set writers and writer name. + +#### ExpandAndCreateOrUpdateDataSetWriterEntries +``` +POST /v2/writer +``` + + +##### Description +Create a series of published nodes entries using the provided entry as template. The entry is expanded using expansion configuration provided. Expanded entries are returned one by one with error information if any. The configuration is also saved in the local configuration store. The server must be online and accessible for the expansion to work. + + +##### Parameters + +|Type|Name|Description|Schema| +|---|---|---|---| +|**Body**|**body**
*required*|The entry to create for the writer and node expansion configuration to use|[PublishedNodeExpansionModelPublishedNodesEntryRequestModel](definitions.md#publishednodeexpansionmodelpublishednodesentryrequestmodel)| + + +##### Responses + +|HTTP Code|Description|Schema| +|---|---|---| +|**200**|The item was created|[PublishedNodesEntryModelServiceResponseIAsyncEnumerable](definitions.md#publishednodesentrymodelserviceresponseiasyncenumerable)| +|**400**|The passed in information is invalid|[ProblemDetails](definitions.md#problemdetails)| +|**403**|A unique item could not be found to update.|[ProblemDetails](definitions.md#problemdetails)| +|**408**|The operation timed out.|[ProblemDetails](definitions.md#problemdetails)| +|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)| + + +##### Consumes + +* `application/json` +* `application/x-msgpack` + + +##### Produces + +* `application/json` +* `application/x-msgpack` + + #### CreateOrUpdateDataSetWriterEntry ``` @@ -3337,7 +3378,7 @@ PUT /v2/writer ##### Description -Create a published nodes entry for a specific writer group and dataset writer. The entry must specify a unique writer group and dataset writer id. A null value is treated as empty string. If the entry is found it is updated, if it is not found, it is created. If more than one entry is found with the same writer group and writer id an error is returned. The writer entry provided must include at least one node which will be the initial set. All nodes must specify a unique dataSetFieldId. A null value is treated as empty string. Publishing intervals at node level are also not supported and generate an error. Publishing intervals must be configured at the data set writer level. +Create a published nodes entry for a specific writer group and dataset writer. The entry must specify a unique writer group and dataset writer id. A null value is treated as empty string. If the entry is found it is replaced, if it is not found, it is created. If more than one entry is found with the same writer group and writer id an error is returned. The writer entry provided must include at least one node which will be the initial set. All nodes must specify a unique dataSetFieldId. A null value is treated as empty string. Publishing intervals at node level are also not supported and generate an error. Publishing intervals must be configured at the data set writer level. ##### Parameters @@ -3368,6 +3409,47 @@ Create a published nodes entry for a specific writer group and dataset writer. T * `application/x-msgpack` + +#### ExpandWriter +``` +POST /v2/writer/expand +``` + + +##### Description +Expands the provided nodes in the entry to a series of published node entries. The provided entry is used template. The entry is expanded using expansion configuration provided. Expanded entries are returned one by one with error information if any. The configuration is not updated but the resulting entries can be modified and later saved in the configuration using the configuration API. The server must be online and accessible for the expansion to work. + + +##### Parameters + +|Type|Name|Description|Schema| +|---|---|---|---| +|**Body**|**body**
*required*|The entry to expand and the node expansion configuration to use. If no configuration is provided a default configuration is used which and no error entries are returned.|[PublishedNodeExpansionModelPublishedNodesEntryRequestModel](definitions.md#publishednodeexpansionmodelpublishednodesentryrequestmodel)| + + +##### Responses + +|HTTP Code|Description|Schema| +|---|---|---| +|**200**|The item was created|[PublishedNodesEntryModelServiceResponseIAsyncEnumerable](definitions.md#publishednodesentrymodelserviceresponseiasyncenumerable)| +|**400**|The passed in information is invalid|[ProblemDetails](definitions.md#problemdetails)| +|**403**|A unique item could not be found to update.|[ProblemDetails](definitions.md#problemdetails)| +|**408**|The operation timed out.|[ProblemDetails](definitions.md#problemdetails)| +|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)| + + +##### Consumes + +* `application/json` +* `application/x-msgpack` + + +##### Produces + +* `application/json` +* `application/x-msgpack` + + #### GetDataSetWriterEntry ``` diff --git a/docs/opc-publisher/commandline.md b/docs/opc-publisher/commandline.md index 2774040073..e7b22fc340 100644 --- a/docs/opc-publisher/commandline.md +++ b/docs/opc-publisher/commandline.md @@ -19,7 +19,7 @@ Secrets such as `EdgeHubConnectionString`, other connection strings, or the `Api ██║ ██║██╔═══╝ ██║ ██╔═══╝ ██║ ██║██╔══██╗██║ ██║╚════██║██╔══██║██╔══╝ ██╔══██╗ ╚██████╔╝██║ ╚██████╗ ██║ ╚██████╔╝██████╔╝███████╗██║███████║██║ ██║███████╗██║ ██║ ╚═════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝╚═╝╚══════╝╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝ - 2.9.11 (.NET 8.0.7/win-x64/OPC Stack 1.5.374.78) + 2.9.12 (.NET 8.0.8/win-x64/OPC Stack 1.5.374.78) General ------- diff --git a/docs/opc-publisher/definitions.md b/docs/opc-publisher/definitions.md index 8046033c3b..115a28c2f2 100644 --- a/docs/opc-publisher/definitions.md +++ b/docs/opc-publisher/definitions.md @@ -1674,6 +1674,56 @@ A monitored and published item |**samplingInterval**
*optional*|Sampling interval to use|string (date-span)| + +### PublishedNodeExpansionModel +Node expansion configuration. Configures how an entry should + be expanded into configuration. If a node is an object it is + expanded to all contained variables. + + + + If a node is an object type, all objects of that type are + searched from the object root node. These found objects are + then expanded into their variables. + + + + If the node is a variable, the variable is expanded to include + all contained variables or properties. All entries will have + the data set field id configured as data set class id. + + + + If a node is a variable type, then all variables of this type + are found and added to a single writer entry. Note: That by + themselves these variables are no further expanded. + + +|Name|Description|Schema| +|---|---|---| +|**createSingleWriter**
*optional*|By default the api will create a new distinct
writer per expanded object. Objects that cannot
be expanded are part of the originally provided
writer. The writer id is then post fixed with
the data set field id of the object node field.
If true, all variables of all expanded nodes are
added to the originally provided entry.|boolean| +|**discardErrors**
*optional*|Errors are silently discarded and only
successfully expanded nodes are returned.|boolean| +|**excludeRootIfInstanceNode**
*optional*|If the node is an object or variable instance do
not include it but only the instances underneath
them.|boolean| +|**header**
*optional*||[RequestHeaderModel](definitions.md#requestheadermodel)| +|**maxDepth**
*optional*|Max browse depth for object search operation or
when searching for an instance of a type.
To only expand an object to its variables set
this value to 0. The depth of expansion of a
variable itself can be controlled via the
Azure.IIoT.OpcUa.Publisher.Models.PublishedNodeExpansionModel.MaxLevelsToExpand" property.
If the root object is excluded a value of 0 is
equivalent to a value of 1 to get the first level
of objects contained in the object but not the
object itself, e.g. a folder object.|integer (int64)| +|**maxLevelsToExpand**
*optional*|Max number of levels to expand an instance node
such as an object or variable into resulting
variables.
If the node is a variable instance to start with
but the Azure.IIoT.OpcUa.Publisher.Models.PublishedNodeExpansionModel.ExcludeRootIfInstanceNode
property is set to excluded it, then setting this
value to 0 is equivalent to a value of 1 to get
the first level of variables contained in the
variable, but not the variable itself. Otherwise
only the variable itelf is returned. If the node
is an object instance, 0 is equivalent to
infinite and all levels are expanded.|integer (int64)| +|**noSubTypesOfTypeNodes**
*optional*|Do not consider subtypes of an object type when
searching for instances of the type.|boolean| +|**stopAtFirstFoundInstance**
*optional*|If the depth is not limited and the node is a
type definition id set this flag to true to find
only the first instance of this type from the
object root.|boolean| + + + +### PublishedNodeExpansionModelPublishedNodesEntryRequestModel +Wraps a request and a published nodes entry to bind to a +body more easily for api that requires an entry and additional +configuration + + +|Name|Schema| +|---|---| +|**entry**
*required*|[PublishedNodesEntryModel](definitions.md#publishednodesentrymodel)| +|**request**
*optional*|[PublishedNodeExpansionModel](definitions.md#publishednodeexpansionmodel)| + + ### PublishedNodesEntryModel Contains the nodes which should be published @@ -1698,6 +1748,9 @@ Contains the nodes which should be published |**DataSetWriterGroup**
*optional*|The Group the writer belongs to.|string| |**DataSetWriterId**
*optional*|Name of the data set writer.|string| |**DataSetWriterWatchdogBehavior**
*optional*||[SubscriptionWatchdogBehavior](definitions.md#subscriptionwatchdogbehavior)| +|**DefaultHeartbeatBehavior**
*optional*||[HeartbeatBehavior](definitions.md#heartbeatbehavior)| +|**DefaultHeartbeatInterval**
*optional*|Default heartbeat interval in milliseconds|integer (int32)| +|**DefaultHeartbeatIntervalTimespan**
*optional*|Default heartbeat interval for all nodes as duration. Takes
precedence over Azure.IIoT.OpcUa.Publisher.Models.PublishedNodesEntryModel.DefaultHeartbeatInterval if
defined.|string (date-span)| |**DisableSubscriptionTransfer**
*optional*|Disable subscription transfer on reconnect|boolean| |**DumpConnectionDiagnostics**
*optional*|Dump server diagnostics for the connection to enable
advanced troubleshooting scenarios.|boolean| |**EncryptedAuthPassword**
*optional*|encrypted password|string| @@ -1724,6 +1777,7 @@ Contains the nodes which should be published |**Priority**
*optional*|Priority of the writer subscription.|integer (int32)| |**QualityOfService**
*optional*||[QoS](definitions.md#qos)| |**QueueName**
*optional*|Writer queue overrides the writer group queue name.
Network messages are then split across queues with
Qos also accounted for.|string| +|**RepublishAfterTransfer**
*optional*|Republish after transferring the subscription during
reconnect handling unless subscription transfer was disabled.|boolean| |**SendKeepAliveDataSetMessages**
*optional*|Send a keep alive message when a subscription keep
alive notification is received inside the writer. If keep
alive messages are not supported by the messaging
profile chosen this value is ignored.|boolean| |**UseReverseConnect**
*optional*|Use reverse connect to connect ot the endpoint|boolean| |**UseSecurity**
*optional*|Secure transport should be used to connect to
the opc server.|boolean| @@ -1734,7 +1788,11 @@ Contains the nodes which should be published |**WriterGroupQualityOfService**
*optional*||[QoS](definitions.md#qos)| |**WriterGroupQueueName**
*optional*|Writer group queue overrides the default writer group
topic template to use.|string| |**WriterGroupTransport**
*optional*||[WriterGroupTransport](definitions.md#writergrouptransport)| -|**republishAfterTransfer**
*optional*|Republish after transferring the subscription during
reconnect handling unless subscription transfer was disabled.|boolean| + + + +### PublishedNodesEntryModelServiceResponseIAsyncEnumerable +*Type* : object diff --git a/docs/opc-publisher/features.md b/docs/opc-publisher/features.md index 19b7ad3d44..287e02776c 100644 --- a/docs/opc-publisher/features.md +++ b/docs/opc-publisher/features.md @@ -11,10 +11,11 @@ The following table shows the supported features of OPC Publisher and planned fe | Secure channel transport and configuration ||X|X|| | OPC UA HTTP transport and configuration ||-|-|#1997| | Secure channel over web socket transport and configuration ||-|-|#1997| -| Secure channel certificate management API ||||| +| Secure channel certificate management [API](./api.md#certificates) ||||| | | Client Cert |-|X|| | | Using EST |-|-|| -| | GDS Push |-|-|| +| | GDS Pull from GDS server |-|-|#2081| +| | GDS Server Push to OPC Publisher |-|-|| | Session-reconnect handling across connection loss ||X|X|| | | Using official .net stack implementation |-|X|| | [Reverse Connect](./readme.md#using-opc-ua-reverse-connect) ||-|X|Preview| @@ -28,7 +29,7 @@ The following table shows the supported features of OPC Publisher and planned fe | Browse [API](./api.md#browse) ||-|X|| | | Browse first/next |-|X|| | | RegEx Browse filter |-|-|| -| | Streaming browse “Fast browsing” / Partial node set export |-|X|Preview| +| | Streaming browse "Fast browsing" / Partial node set export |-|X|Preview| | | Publish model change feed change events |-|X|Experimental| | Translate browse path [API](./api.md) ||-|X|| | Read [API](./api.md#valueread) ||||| @@ -45,6 +46,15 @@ The following table shows the supported features of OPC Publisher and planned fe | | Update |-|X|Preview| | | Upsert |-|X|Preview| | | Delete |-|X|Preview| +| File Transfer [API](./api.md#filesystem) ([Part 20](https://reference.opcfoundation.org/Core/Part20/v105/docs/)) ||||| +| | List file systems |-|X|Experimental| +| | Browse file systems on server |-|X|Experimental| +| | Create files and directories |-|X|Experimental| +| | Download files |-|X|Experimental| +| | Upload files to directory |-|X|Experimental| +| | Delete files and directories |-|X|Experimental| +| | Substitutable Close method |-|-|#2322| +| | Temporary file transfer |-|-|| | Subscribe to [value changes](./readme.md#configuration-schema) ||||| | | Value change subscriptions |X|X|| | | Data change filter support |-|X|| @@ -53,22 +63,32 @@ The following table shows the supported features of OPC Publisher and planned fe | | Status trigger |-|X|| | | Set server queue size per value|-|X|| | | Set server queue LIFO/FIFO behavior per value|-|X|| +| | Set queue size using publishing interval and sampling interval |-|X|Preview| | | Periodic read ([cyclic read](./readme.md#sampling-and-publishing-interval-configuration))|-|X|Preview| | | Heartbeat (Periodic resending of last known value) |X|X|| | | Configurable heartbeat behavior (LKG, LKV) ||X|| | | Heartbeat message timestamp source configuration ||X|| -| | Subscribe to all nodes under an Object node |-|-|#1320| | Subscribe to [events](./readme.md#configuring-event-subscriptions) ||||| | | Using browse path to event notifier |-|X|| | | Simple (get all events of a type from event notifier)|-|X|| | | Event filter (filter events on server before sending)|-|X|| | | Condition handling / Condition snapshots|-|X|Preview| +| Subscribe to nodes that are not variables or event notifiers ||||| +| | All variables under and object |-|X|Preview| +| | All Objects and variables of an object type |-|X|Preview| +| | All Variables of a variable type |-|X|Preview| | Triggering ||||| | | Using Server side triggering service (SetTriggering) |-|-|| | | Client side sampling of values on event |-|-|| -| Re-evaluate subscriptions periodically ||||| +| Re-evaluate subscriptions ||||| | | Periodically |-|X|| +| | While monitored items failed to be applied |-|X|| | | On data model change events |-|-|#1209| +| Subscription watchdog ||||| +| | When all monitored items are not reporting within an interval |-|X|| +| | When a monitored item is not reporting within an interval |-|X|| +| | When subscription is deleted on server |-|X|| +| | Configure whether to reset or terminate |-|X|| | Registered Nodes ||||| | | For periodic reads (registered read) |-|X|Preview| | | For monitored items |-|X|Preview| @@ -93,7 +113,9 @@ The following table shows the supported features of OPC Publisher and planned fe | | v2.8 |X|X|| | | v2.9 |-|X|| | | JSON schema validation |X|-|| -| OPC UA Pub/Sub configuration API (Part 14)||-|-|| +| | Bootstrapped from Azure Storage blob |-|-|#2284| +| OPC UA Pub/Sub configuration API ([Part 14](https://reference.opcfoundation.org/Core/Part14/v105/docs/))||-|-|| +| Asset WoT configuration API ([Part 10100-1](https://reference.opcfoundation.org/WoT/v100/docs/))||-|-|| | Data contextualization ||||| | | Add Endpoint/Dataset name to message header (Routing) |X|X|| | | [Enrichment](./readme.md#key-frames-delta-frames-and-extension-fields) |-|X|| @@ -140,16 +162,16 @@ The following table shows the supported features of OPC Publisher and planned fe | | Configurable per writer group |-|X|| | OPC UA Pub Sub message [encoding](./messageformats.md) ||||| | | JSON Encoding |X|X|| -| | JSON Encoding per standard |-|X|| +| | JSON Encoding per [Part 6](https://reference.opcfoundation.org/Core/Part6/v105/docs/) |-|X|| | | GZIP JSON Encoding |-|X|| | | JSON Schema publishing for JSON encoding |-|X|Experimental| -| | UADP Binary encoding |-|X|Preview| +| | UADP Binary encoding per [Part 14](https://reference.opcfoundation.org/Core/Part14/v105/docs/)|-|X|Preview| | | Avro and Avro+Gzip encoding with Schema publishing |-|X|Experimental| | | [Reversible Encoding](./messageformats.md#reversible-encoding) |-|X|Preview| | | [Samples JSON encoding](./messageformats.md#samples-mode-encoding-legacy) – Legacy |X|X|Deprecated| | | Samples Binary encoding – Legacy |X|-|| | | Configurable per writer group |-|X|| -| OPC UA Part 14 Pub Sub Message types ||||| +| OPC UA [Part 14](https://reference.opcfoundation.org/Core/Part14/v105/docs/) Pub Sub Message types ||||| | | [Delta frame messages](./messageformats.md#data-value-change-messages) |-|X|| | | [Key frame messages](./readme.md#key-frames-delta-frames-and-extension-fields) / Key frame count |-|X|| | | [Event messages](./messageformats.md#event-messages) |-|X|| diff --git a/docs/opc-publisher/openapi.json b/docs/opc-publisher/openapi.json index b06a870dcd..d0ad4fa02a 100644 --- a/docs/opc-publisher/openapi.json +++ b/docs/opc-publisher/openapi.json @@ -4455,7 +4455,7 @@ "Writer" ], "summary": "CreateOrUpdateDataSetWriterEntry", - "description": "Create a published nodes entry for a specific writer group and dataset writer. The entry must specify a unique writer group and dataset writer id. A null value is treated as empty string. If the entry is found it is updated, if it is not found, it is created. If more than one entry is found with the same writer group and writer id an error is returned. The writer entry provided must include at least one node which will be the initial set. All nodes must specify a unique dataSetFieldId. A null value is treated as empty string. Publishing intervals at node level are also not supported and generate an error. Publishing intervals must be configured at the data set writer level.", + "description": "Create a published nodes entry for a specific writer group and dataset writer. The entry must specify a unique writer group and dataset writer id. A null value is treated as empty string. If the entry is found it is replaced, if it is not found, it is created. If more than one entry is found with the same writer group and writer id an error is returned. The writer entry provided must include at least one node which will be the initial set. All nodes must specify a unique dataSetFieldId. A null value is treated as empty string. Publishing intervals at node level are also not supported and generate an error. Publishing intervals must be configured at the data set writer level.", "operationId": "CreateOrUpdateDataSetWriterEntry", "consumes": [ "application/json", @@ -4493,6 +4493,65 @@ } } } + }, + "post": { + "tags": [ + "Writer" + ], + "summary": "ExpandAndCreateOrUpdateDataSetWriterEntries", + "description": "Create a series of published nodes entries using the provided entry as template. The entry is expanded using expansion configuration provided. Expanded entries are returned one by one with error information if any. The configuration is also saved in the local configuration store. The server must be online and accessible for the expansion to work.", + "operationId": "ExpandAndCreateOrUpdateDataSetWriterEntries", + "consumes": [ + "application/json", + "application/x-msgpack" + ], + "produces": [ + "application/json", + "application/x-msgpack" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "The entry to create for the writer and node expansion configuration to use", + "required": true, + "schema": { + "$ref": "#/definitions/PublishedNodeExpansionModelPublishedNodesEntryRequestModel" + } + } + ], + "responses": { + "200": { + "description": "The item was created", + "schema": { + "$ref": "#/definitions/PublishedNodesEntryModelServiceResponseIAsyncEnumerable" + } + }, + "400": { + "description": "The passed in information is invalid", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "403": { + "description": "A unique item could not be found to update.", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "408": { + "description": "The operation timed out.", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "500": { + "description": "An unexpected error occurred", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + } + } } }, "/v2/writer/{dataSetWriterGroup}/{dataSetWriterId}": { @@ -5018,6 +5077,67 @@ "nextLinkName": "lastDataSetFieldId" } } + }, + "/v2/writer/expand": { + "post": { + "tags": [ + "Writer" + ], + "summary": "ExpandWriter", + "description": "Expands the provided nodes in the entry to a series of published node entries. The provided entry is used template. The entry is expanded using expansion configuration provided. Expanded entries are returned one by one with error information if any. The configuration is not updated but the resulting entries can be modified and later saved in the configuration using the configuration API. The server must be online and accessible for the expansion to work.", + "operationId": "ExpandWriter", + "consumes": [ + "application/json", + "application/x-msgpack" + ], + "produces": [ + "application/json", + "application/x-msgpack" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "The entry to expand and the node expansion configuration to use. If no configuration is provided a default configuration is used which and no error entries are returned.", + "required": true, + "schema": { + "$ref": "#/definitions/PublishedNodeExpansionModelPublishedNodesEntryRequestModel" + } + } + ], + "responses": { + "200": { + "description": "The item was created", + "schema": { + "$ref": "#/definitions/PublishedNodesEntryModelServiceResponseIAsyncEnumerable" + } + }, + "400": { + "description": "The passed in information is invalid", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "403": { + "description": "A unique item could not be found to update.", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "408": { + "description": "The operation timed out.", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + }, + "500": { + "description": "An unexpected error occurred", + "schema": { + "$ref": "#/definitions/ProblemDetails" + } + } + } + } } }, "definitions": { @@ -8471,6 +8591,62 @@ }, "additionalProperties": false }, + "PublishedNodeExpansionModel": { + "description": "\r\n\r\n Node expansion configuration. Configures how an entry should\r\n be expanded into configuration. If a node is an object it is\r\n expanded to all contained variables.\r\n \r\n\r\n\r\n If a node is an object type, all objects of that type are\r\n searched from the object root node. These found objects are\r\n then expanded into their variables.\r\n \r\n\r\n\r\n If the node is a variable, the variable is expanded to include\r\n all contained variables or properties. All entries will have\r\n the data set field id configured as data set class id.\r\n \r\n\r\n\r\n If a node is a variable type, then all variables of this type\r\n are found and added to a single writer entry. Note: That by\r\n themselves these variables are no further expanded.\r\n ", + "type": "object", + "properties": { + "header": { + "$ref": "#/definitions/RequestHeaderModel" + }, + "createSingleWriter": { + "description": "By default the api will create a new distinct\r\nwriter per expanded object. Objects that cannot\r\nbe expanded are part of the originally provided\r\nwriter. The writer id is then post fixed with\r\nthe data set field id of the object node field.\r\nIf true, all variables of all expanded nodes are\r\nadded to the originally provided entry.", + "type": "boolean" + }, + "maxLevelsToExpand": { + "format": "int64", + "description": "Max number of levels to expand an instance node\r\nsuch as an object or variable into resulting\r\nvariables.\r\nIf the node is a variable instance to start with\r\nbut the Azure.IIoT.OpcUa.Publisher.Models.PublishedNodeExpansionModel.ExcludeRootIfInstanceNode\r\nproperty is set to excluded it, then setting this\r\nvalue to 0 is equivalent to a value of 1 to get\r\nthe first level of variables contained in the\r\nvariable, but not the variable itself. Otherwise\r\nonly the variable itelf is returned. If the node\r\nis an object instance, 0 is equivalent to\r\ninfinite and all levels are expanded.", + "type": "integer" + }, + "noSubTypesOfTypeNodes": { + "description": "Do not consider subtypes of an object type when\r\nsearching for instances of the type.", + "type": "boolean" + }, + "excludeRootIfInstanceNode": { + "description": "If the node is an object or variable instance do\r\nnot include it but only the instances underneath\r\nthem.", + "type": "boolean" + }, + "maxDepth": { + "format": "int64", + "description": "Max browse depth for object search operation or\r\nwhen searching for an instance of a type.\r\nTo only expand an object to its variables set\r\nthis value to 0. The depth of expansion of a\r\nvariable itself can be controlled via the\r\nAzure.IIoT.OpcUa.Publisher.Models.PublishedNodeExpansionModel.MaxLevelsToExpand\" property.\r\nIf the root object is excluded a value of 0 is\r\nequivalent to a value of 1 to get the first level\r\nof objects contained in the object but not the\r\nobject itself, e.g. a folder object.", + "type": "integer" + }, + "stopAtFirstFoundInstance": { + "description": "If the depth is not limited and the node is a\r\ntype definition id set this flag to true to find\r\nonly the first instance of this type from the\r\nobject root.", + "type": "boolean" + }, + "discardErrors": { + "description": "Errors are silently discarded and only\r\nsuccessfully expanded nodes are returned.", + "type": "boolean" + } + }, + "additionalProperties": false + }, + "PublishedNodeExpansionModelPublishedNodesEntryRequestModel": { + "description": "Wraps a request and a published nodes entry to bind to a\r\nbody more easily for api that requires an entry and additional\r\nconfiguration", + "required": [ + "entry" + ], + "type": "object", + "properties": { + "entry": { + "$ref": "#/definitions/PublishedNodesEntryModel" + }, + "request": { + "$ref": "#/definitions/PublishedNodeExpansionModel" + } + }, + "additionalProperties": false + }, "PublishedNodesEntryModel": { "description": "Contains the nodes which should be published", "required": [ @@ -8656,7 +8832,7 @@ "description": "Disable subscription transfer on reconnect", "type": "boolean" }, - "republishAfterTransfer": { + "RepublishAfterTransfer": { "description": "Republish after transferring the subscription during\r\nreconnect handling unless subscription transfer was disabled.", "type": "boolean" }, @@ -8703,6 +8879,19 @@ "description": "Message retention setting for messages sent by\r\nthe writer if the transport supports it.", "type": "boolean" }, + "DefaultHeartbeatInterval": { + "format": "int32", + "description": "Default heartbeat interval in milliseconds", + "type": "integer" + }, + "DefaultHeartbeatIntervalTimespan": { + "format": "date-span", + "description": "Default heartbeat interval for all nodes as duration. Takes\r\nprecedence over Azure.IIoT.OpcUa.Publisher.Models.PublishedNodesEntryModel.DefaultHeartbeatInterval if\r\ndefined.", + "type": "string" + }, + "DefaultHeartbeatBehavior": { + "$ref": "#/definitions/HeartbeatBehavior" + }, "DumpConnectionDiagnostics": { "description": "Dump server diagnostics for the connection to enable\r\nadvanced troubleshooting scenarios.", "type": "boolean" @@ -8713,6 +8902,10 @@ }, "additionalProperties": false }, + "PublishedNodesEntryModelServiceResponseIAsyncEnumerable": { + "type": "object", + "additionalProperties": false + }, "PublishedNodesResponseModel": { "description": "PublishNodes direct method response", "type": "object", diff --git a/e2e-tests/IIoTPlatform-E2E-Tests/IIoTPlatform-E2E-Tests.csproj b/e2e-tests/IIoTPlatform-E2E-Tests/IIoTPlatform-E2E-Tests.csproj index 200f53e4bd..8ec5f56655 100644 --- a/e2e-tests/IIoTPlatform-E2E-Tests/IIoTPlatform-E2E-Tests.csproj +++ b/e2e-tests/IIoTPlatform-E2E-Tests/IIoTPlatform-E2E-Tests.csproj @@ -11,13 +11,13 @@ - - + + - + runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/e2e-tests/OpcPublisher-E2E-Tests/OpcPublisher-AE-E2E-Tests.csproj b/e2e-tests/OpcPublisher-E2E-Tests/OpcPublisher-AE-E2E-Tests.csproj index 76bef7abbe..9e579ba5ea 100644 --- a/e2e-tests/OpcPublisher-E2E-Tests/OpcPublisher-AE-E2E-Tests.csproj +++ b/e2e-tests/OpcPublisher-E2E-Tests/OpcPublisher-AE-E2E-Tests.csproj @@ -11,13 +11,14 @@ - + + - + - + diff --git a/samples/Netcap/src/Netcap.csproj b/samples/Netcap/src/Netcap.csproj index fb0e593cf9..d6dd63f8b0 100644 --- a/samples/Netcap/src/Netcap.csproj +++ b/samples/Netcap/src/Netcap.csproj @@ -18,10 +18,11 @@ - - + + + @@ -29,5 +30,6 @@ + \ No newline at end of file diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/Azure.IIoT.OpcUa.Publisher.Models.csproj b/src/Azure.IIoT.OpcUa.Publisher.Models/src/Azure.IIoT.OpcUa.Publisher.Models.csproj index bae42c71ef..ff8d7076e2 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/Azure.IIoT.OpcUa.Publisher.Models.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/Azure.IIoT.OpcUa.Publisher.Models.csproj @@ -8,6 +8,7 @@ enable
- + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodeExpansionModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodeExpansionModel.cs new file mode 100644 index 0000000000..f017056ec1 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodeExpansionModel.cs @@ -0,0 +1,125 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using System.Runtime.Serialization; + + /// + /// + /// Node expansion configuration. Configures how an entry should + /// be expanded into configuration. If a node is an object it is + /// expanded to all contained variables. + /// + /// + /// If a node is an object type, all objects of that type are + /// searched from the object root node. These found objects are + /// then expanded into their variables. + /// + /// + /// If the node is a variable, the variable is expanded to include + /// all contained variables or properties. All entries will have + /// the data set field id configured as data set class id. + /// + /// + /// If a node is a variable type, then all variables of this type + /// are found and added to a single writer entry. Note: That by + /// themselves these variables are no further expanded. + /// + /// + [DataContract] + public sealed record class PublishedNodeExpansionModel + { + /// + /// Optional request header to use for all operations + /// against the server. + /// + [DataMember(Name = "header", Order = 1, + EmitDefaultValue = false)] + public RequestHeaderModel? Header { get; init; } + + /// + /// By default the api will create a new distinct + /// writer per expanded object. Objects that cannot + /// be expanded are part of the originally provided + /// writer. The writer id is then post fixed with + /// the data set field id of the object node field. + /// If true, all variables of all expanded nodes are + /// added to the originally provided entry. + /// + [DataMember(Name = "createSingleWriter", Order = 2, + EmitDefaultValue = false)] + public bool CreateSingleWriter { get; init; } + + /// + /// Max number of levels to expand an instance node + /// such as an object or variable into resulting + /// variables. + /// If the node is a variable instance to start with + /// but the + /// property is set to excluded it, then setting this + /// value to 0 is equivalent to a value of 1 to get + /// the first level of variables contained in the + /// variable, but not the variable itself. Otherwise + /// only the variable itelf is returned. If the node + /// is an object instance, 0 is equivalent to + /// infinite and all levels are expanded. + /// + [DataMember(Name = "maxLevelsToExpand", Order = 3, + EmitDefaultValue = false)] + public uint MaxLevelsToExpand { get; init; } + + /// + /// Do not consider subtypes of an object type when + /// searching for instances of the type. + /// + [DataMember(Name = "noSubTypesOfTypeNodes", Order = 4, + EmitDefaultValue = false)] + public bool NoSubTypesOfTypeNodes { get; init; } + + /// + /// If the node is an object or variable instance do + /// not include it but only the instances underneath + /// them. + /// + [DataMember(Name = "excludeRootIfInstanceNode", Order = 5, + EmitDefaultValue = false)] + public bool ExcludeRootIfInstanceNode { get; init; } + + /// + /// Max browse depth for object search operation or + /// when searching for an instance of a type. + /// To only expand an object to its variables set + /// this value to 0. The depth of expansion of a + /// variable itself can be controlled via the + /// " property. + /// If the root object is excluded a value of 0 is + /// equivalent to a value of 1 to get the first level + /// of objects contained in the object but not the + /// object itself, e.g. a folder object. + /// + [DataMember(Name = "maxDepth", Order = 6, + EmitDefaultValue = false)] + public uint? MaxDepth { get; init; } + + /// + /// If the depth is not limited and the node is a + /// type definition id set this flag to true to find + /// only the first instance of this type from the + /// object root. + /// + [DataMember(Name = "stopAtFirstFoundInstance", Order = 7, + EmitDefaultValue = false)] + public bool StopAtFirstFoundInstance { get; init; } + + /// + /// Errors are silently discarded and only + /// successfully expanded nodes are returned. + /// + [DataMember(Name = "discardErrors", Order = 8, + EmitDefaultValue = false)] + public bool DiscardErrors { get; init; } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryRequestModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryRequestModel.cs new file mode 100644 index 0000000000..68bcc1bf86 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryRequestModel.cs @@ -0,0 +1,33 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using System.ComponentModel.DataAnnotations; + using System.Runtime.Serialization; + + /// + /// Wraps a request and a published nodes entry to bind to a + /// body more easily for api that requires an entry and additional + /// configuration + /// + /// + [DataContract] + public sealed record class PublishedNodesEntryRequestModel + { + /// + /// Published nodes entry + /// + [DataMember(Name = "entry", Order = 0)] + [Required] + public required PublishedNodesEntryModel Entry { get; init; } + + /// + /// Request + /// + [DataMember(Name = "request", Order = 1)] + public T? Request { get; init; } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj index 62cf15f97e..542015e7c4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Models/tests/Azure.IIoT.OpcUa.Publisher.Models.Tests.csproj @@ -17,9 +17,9 @@ all runtime; build; native; contentfiles; analyzers - - - + + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj index 580adbf240..5ab1325782 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Azure.IIoT.OpcUa.Publisher.Module.Cli.csproj @@ -7,8 +7,8 @@ true - - + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Azure.IIoT.OpcUa.Publisher.Module.csproj b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Azure.IIoT.OpcUa.Publisher.Module.csproj index d1698787ce..ce8b479e8d 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Azure.IIoT.OpcUa.Publisher.Module.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Azure.IIoT.OpcUa.Publisher.Module.csproj @@ -33,14 +33,15 @@ - - - - - - + + + + + + - + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/ConfigurationController.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/ConfigurationController.cs index e540343265..51edf4d910 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/ConfigurationController.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/ConfigurationController.cs @@ -48,7 +48,7 @@ public class ConfigurationController : ControllerBase, IMethodController /// Create publisher methods controller /// /// - public ConfigurationController(IConfigurationServices configServices) + public ConfigurationController(IPublishedNodesServices configServices) { _configServices = configServices; } @@ -422,6 +422,6 @@ public async Task> GetDiagnosticInfoAsync( return await _configServices.GetDiagnosticInfoAsync(ct).ConfigureAwait(false); } - private readonly IConfigurationServices _configServices; + private readonly IPublishedNodesServices _configServices; } } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/LegacyController.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/LegacyController.cs index b4f75c2642..6c1e00c46a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/LegacyController.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/LegacyController.cs @@ -23,7 +23,9 @@ public class LegacyController : IMethodController /// Handler for GetInfo direct method /// /// +#pragma warning disable CA1822 // Mark members as static public Task GetInfoAsync() +#pragma warning restore CA1822 // Mark members as static { return Task.FromException(new NotSupportedException( "GetInfo not supported")); @@ -33,7 +35,9 @@ public Task GetInfoAsync() /// Handler for GetDiagnosticLog direct method - not supported /// /// +#pragma warning disable CA1822 // Mark members as static public Task GetDiagnosticLogAsync() +#pragma warning restore CA1822 // Mark members as static { return Task.FromException(new NotSupportedException( "GetDiagnosticLog not supported")); @@ -43,7 +47,9 @@ public Task GetDiagnosticLogAsync() /// Handler for GetDiagnosticStartupLog direct method - not supported /// /// +#pragma warning disable CA1822 // Mark members as static public Task GetDiagnosticStartupLogAsync() +#pragma warning restore CA1822 // Mark members as static { return Task.FromException(new NotSupportedException( "GetDiagnosticStartupLog not supported")); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/WriterController.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/WriterController.cs index ac80755ab5..e0587d6afc 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/WriterController.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/WriterController.cs @@ -51,12 +51,15 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Controllers public class WriterController : ControllerBase, IMethodController { /// - /// Create writer configuration methods controller + /// Create publisher methods controller /// - /// - public WriterController(IConfigurationServices configServices) + /// + /// + public WriterController(IPublishedNodesServices publisher, + IConfigurationServices configuration) { - _configServices = configServices; + _publisher = publisher; + _configuration = configuration; } /// @@ -65,7 +68,7 @@ public WriterController(IConfigurationServices configServices) /// /// Create a published nodes entry for a specific writer group and dataset writer. /// The entry must specify a unique writer group and dataset writer id. A null value - /// is treated as empty string. If the entry is found it is updated, if it is not + /// is treated as empty string. If the entry is found it is replaced, if it is not /// found, it is created. If more than one entry is found with the same writer group /// and writer id an error is returned. The writer entry provided must include at /// least one node which will be the initial set. All nodes must specify a unique @@ -89,7 +92,7 @@ public async Task CreateOrUpdateDataSetWriterEntryAsync( CancellationToken ct = default) { ArgumentNullException.ThrowIfNull(dataSetWriterEntry); - await _configServices.CreateOrUpdateDataSetWriterEntryAsync( + await _publisher.CreateOrUpdateDataSetWriterEntryAsync( dataSetWriterEntry, ct).ConfigureAwait(false); } @@ -124,7 +127,7 @@ public async Task GetDataSetWriterEntryAsync( { ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); - return await _configServices.GetDataSetWriterEntryAsync( + return await _publisher.GetDataSetWriterEntryAsync( dataSetWriterGroup, dataSetWriterId, ct).ConfigureAwait(false); } @@ -166,7 +169,7 @@ public async Task AddOrUpdateNodesAsync(string dataSetWriterGroup, string dataSe ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); ArgumentNullException.ThrowIfNull(opcNodes); - await _configServices.AddOrUpdateNodesAsync(dataSetWriterGroup, dataSetWriterId, + await _publisher.AddOrUpdateNodesAsync(dataSetWriterGroup, dataSetWriterId, opcNodes, insertAfterFieldId, ct).ConfigureAwait(false); } @@ -208,7 +211,7 @@ public async Task AddOrUpdateNodeAsync(string dataSetWriterGroup, string dataSet ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); ArgumentNullException.ThrowIfNull(opcNode); - await _configServices.AddOrUpdateNodesAsync(dataSetWriterGroup, dataSetWriterId, + await _publisher.AddOrUpdateNodesAsync(dataSetWriterGroup, dataSetWriterId, new[] { opcNode }, insertAfterFieldId, ct).ConfigureAwait(false); } @@ -246,7 +249,7 @@ public async Task RemoveNodesAsync(string dataSetWriterGroup, string dataSetWrit ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); ArgumentNullException.ThrowIfNull(dataSetFieldIds); - await _configServices.RemoveNodesAsync(dataSetWriterGroup, dataSetWriterId, + await _publisher.RemoveNodesAsync(dataSetWriterGroup, dataSetWriterId, dataSetFieldIds, ct).ConfigureAwait(false); } @@ -282,7 +285,7 @@ public async Task RemoveNodeAsync(string dataSetWriterGroup, string dataSetWrite ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); ArgumentNullException.ThrowIfNull(dataSetFieldId); - await _configServices.RemoveNodesAsync(dataSetWriterGroup, dataSetWriterId, + await _publisher.RemoveNodesAsync(dataSetWriterGroup, dataSetWriterId, new[] { dataSetFieldId }, ct).ConfigureAwait(false); } @@ -321,7 +324,7 @@ public async Task GetNodeAsync( ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); ArgumentNullException.ThrowIfNull(dataSetFieldId); - return await _configServices.GetNodeAsync( + return await _publisher.GetNodeAsync( dataSetWriterGroup, dataSetWriterId, dataSetFieldId, ct).ConfigureAwait(false); } @@ -374,7 +377,7 @@ public async Task> GetNodesAsync( CultureInfo.InvariantCulture); } } - return await _configServices.GetNodesAsync(dataSetWriterGroup, dataSetWriterId, + return await _publisher.GetNodesAsync(dataSetWriterGroup, dataSetWriterId, lastDataSetFieldId, pageSize, ct).ConfigureAwait(false); } @@ -408,10 +411,86 @@ public async Task RemoveDataSetWriterEntryAsync(string dataSetWriterGroup, { ArgumentNullException.ThrowIfNull(dataSetWriterGroup); ArgumentNullException.ThrowIfNull(dataSetWriterId); - await _configServices.RemoveDataSetWriterEntryAsync(dataSetWriterGroup, + await _publisher.RemoveDataSetWriterEntryAsync(dataSetWriterGroup, dataSetWriterId, force, ct).ConfigureAwait(false); } - private readonly IConfigurationServices _configServices; + /// + /// ExpandWriter + /// + /// + /// Expands the provided nodes in the entry to a series of published node entries. + /// The provided entry is used template. The entry is expanded using expansion + /// configuration provided. Expanded entries are returned one by one with error + /// information if any. The configuration is not updated but the resulting entries + /// can be modified and later saved in the configuration using the configuration + /// API. The server must be online and accessible + /// for the expansion to work. + /// + /// The entry to expand and the node expansion configuration + /// to use. If no configuration is provided a default configuration is used which + /// and no error entries are returned. + /// + /// + /// is null. + /// The item was created + /// The passed in information is invalid + /// A unique item could not be found to update. + /// The operation timed out. + /// An unexpected error occurred + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + [HttpPost("expand")] + public IAsyncEnumerable> ExpandWriterAsync( + [FromBody][Required] PublishedNodesEntryRequestModel request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Entry); + var expansion = request.Request ?? new PublishedNodeExpansionModel { DiscardErrors = true }; + return _configuration.ExpandAsync(request.Entry, expansion, ct); + } + + /// + /// ExpandAndCreateOrUpdateDataSetWriterEntries + /// + /// + /// Create a series of published nodes entries using the provided entry as template. + /// The entry is expanded using expansion configuration provided. Expanded entries + /// are returned one by one with error information if any. The configuration is also + /// saved in the local configuration store. The server must be online and accessible + /// for the expansion to work. + /// + /// The entry to create for the writer and node expansion + /// configuration to use + /// + /// + /// is null. + /// The item was created + /// The passed in information is invalid + /// A unique item could not be found to update. + /// The operation timed out. + /// An unexpected error occurred + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status403Forbidden)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)] + [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)] + [HttpPost] + public IAsyncEnumerable> ExpandAndCreateOrUpdateDataSetWriterEntriesAsync( + [FromBody][Required] PublishedNodesEntryRequestModel request, + CancellationToken ct = default) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(request.Entry); + var expansion = request.Request ?? new PublishedNodeExpansionModel { DiscardErrors = false }; + return _configuration.CreateOrUpdateAsync(request.Entry, expansion, ct); + } + + private readonly IPublishedNodesServices _publisher; + private readonly IConfigurationServices _configuration; } } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs index 7818054a05..96585fd82a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Startup.cs @@ -121,7 +121,9 @@ public void ConfigureServices(IServiceCollection services) /// /// /// +#pragma warning disable CA1822 // Mark members as static public void Configure(IApplicationBuilder app, IHostApplicationLifetime appLifetime) +#pragma warning restore CA1822 // Mark members as static { app.UseRouting(); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj index 11cc1ad12b..6adcef0523 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Azure.IIoT.OpcUa.Publisher.Module.Tests.csproj @@ -5,7 +5,7 @@ - + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/ConfigurationServicesRestClient.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/ConfigurationServicesRestClient.cs new file mode 100644 index 0000000000..a35c83379d --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/ConfigurationServicesRestClient.cs @@ -0,0 +1,91 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Module.Tests.Clients +{ + using Azure.IIoT.OpcUa.Publisher.Models; + using Azure.IIoT.OpcUa.Publisher.Sdk; + using Furly.Extensions.Serializers; + using Furly.Extensions.Serializers.Newtonsoft; + using Microsoft.Extensions.Options; + using System; + using System.Collections.Generic; + using System.Net.Http; + using System.Threading; + + /// + /// Implementation of file system services over http + /// + public sealed class ConfigurationServicesRestClient : IConfigurationServices + { + /// + /// Create service client + /// + /// + /// + /// + public ConfigurationServicesRestClient(IHttpClientFactory httpClient, + IOptions options, ISerializer serializer) : + this(httpClient, options?.Value.Target, serializer) + { + } + + /// + /// Create service client + /// + /// + /// + /// + public ConfigurationServicesRestClient(IHttpClientFactory httpClient, string serviceUri, + ISerializer serializer = null) + { + if (string.IsNullOrWhiteSpace(serviceUri)) + { + throw new ArgumentNullException(nameof(serviceUri), + "Please configure the Url of the endpoint micro service."); + } + _serviceUri = serviceUri.TrimEnd('/'); + _serializer = serializer ?? new NewtonsoftJsonSerializer(); + _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient)); + } + + /// + public IAsyncEnumerable> ExpandAsync( + PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(entry); + var uri = new Uri($"{_serviceUri}/v2/writer/expand"); + return _httpClient.PostStreamAsync>(uri, + RequestBody(entry, request), _serializer, ct: ct); + } + + /// + public IAsyncEnumerable> CreateOrUpdateAsync( + PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(entry); + var uri = new Uri($"{_serviceUri}/v2/writer"); + return _httpClient.PostStreamAsync>(uri, + RequestBody(entry, request), _serializer, ct: ct); + } + + /// + /// Create envelope + /// + /// + /// + /// + /// + private static PublishedNodesEntryRequestModel RequestBody(PublishedNodesEntryModel entry, + T request) + { + return new PublishedNodesEntryRequestModel { Entry = entry, Request = request }; + } + + private readonly IHttpClientFactory _httpClient; + private readonly ISerializer _serializer; + private readonly string _serviceUri; + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs index 3aa64fb2f7..c8bee81330 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/DmApiPublisherControllerTests.cs @@ -87,12 +87,12 @@ protected override void Dispose(bool disposing) /// /// This method should be called only after content of _tempFile is set. /// - private PublisherConfigurationService InitPublisherConfigService() + private PublishedNodesJsonServices InitPublisherConfigService() { - var configService = new PublisherConfigurationService( + var configService = new PublishedNodesJsonServices( _publishedNodesJobConverter, _publisher, - _loggerFactory.CreateLogger(), + _loggerFactory.CreateLogger(), _publishedNodesProvider, _newtonSoftJsonSerializer, _diagnostic.Object @@ -481,7 +481,7 @@ public async Task DmApiGetConfiguredNodesOnEndpointAsyncUsernamePasswordTestAsyn /// /// /// - private async Task<(PublisherConfigurationService, ConfigurationController)> PublishNodeAsync(string publishedNodesFile, + private async Task<(PublishedNodesJsonServices, ConfigurationController)> PublishNodeAsync(string publishedNodesFile, Func predicate = null) { CopyContent("Resources/empty_pn.json", _tempFile); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/Json/ExpandTests1.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/Json/ExpandTests1.cs new file mode 100644 index 0000000000..70c8bf8796 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/Json/ExpandTests1.cs @@ -0,0 +1,178 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Module.Tests.Controller.TestData.Json +{ + using Autofac; + using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures; + using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures; + using Azure.IIoT.OpcUa.Publisher.Testing.Tests; + using System; + using System.Threading.Tasks; + using Xunit; + using Xunit.Abstractions; + + [Collection(ReadCollection.Name)] + public sealed class ExpandTests1 : IClassFixture, IDisposable + { + public ExpandTests1(TestDataServer server, PublisherModuleFixture module, ITestOutputHelper output) + { + _server = server; + _client = module.CreateRestClientContainer(output, TestSerializerType.Json); + } + + public void Dispose() + { + _client.Dispose(); + } + + private ConfigurationTests1 GetTests() + { + return new ConfigurationTests1(_client.Resolve(), + _server.GetConnection()); + } + + private readonly TestDataServer _server; + private readonly IContainer _client; + + [Fact] + public Task ExpandObjectWithBrowsePathTest1Async() + { + return GetTests().ExpandObjectWithBrowsePathTest1Async(); + } + + [Fact] + public Task ExpandObjectWithBrowsePathTest2Async() + { + return GetTests().ExpandObjectWithBrowsePathTest2Async(); + } + + [Fact] + public Task ExpandObjectTest1Async() + { + return GetTests().ExpandObjectTest1Async(); + } + + [Fact] + public Task ExpandObjectTest2Async() + { + return GetTests().ExpandObjectTest2Async(); + } + + [Fact] + public Task ExpandServerObjectTest1Async() + { + return GetTests().ExpandServerObjectTest1Async(); + } + + [Fact] + public Task ExpandServerObjectTest2Async() + { + return GetTests().ExpandServerObjectTest2Async(); + } + + [Fact] + public Task ExpandServerObjectTest3Async() + { + return GetTests().ExpandServerObjectTest3Async(); + } + + [Fact] + public Task ExpandServerObjectTest4Async() + { + return GetTests().ExpandServerObjectTest4Async(); + } + + [Fact] + public Task ExpandServerObjectTest5Async() + { + return GetTests().ExpandServerObjectTest5Async(); + } + + [Fact] + public Task ExpandBaseObjectTypeTest1Async() + { + return GetTests().ExpandBaseObjectTypeTest1Async(); + } + + [Fact] + public Task ExpandBaseObjectsAndObjectTypesTestAsync() + { + return GetTests().ExpandBaseObjectsAndObjectTypesTestAsync(); + } + + [Fact] + public Task ExpandVariablesTest1Async() + { + return GetTests().ExpandVariablesTest1Async(); + } + + [Fact] + public Task ExpandVariablesAndObjectsTest1Async() + { + return GetTests().ExpandVariablesAndObjectsTest1Async(); + } + + [Fact] + public Task ExpandVariableTypesTest1Async() + { + return GetTests().ExpandVariableTypesTest1Async(); + } + + [Fact] + public Task ExpandVariableTypesTest2Async() + { + return GetTests().ExpandVariableTypesTest2Async(); + } + + [Fact] + public Task ExpandVariableTypesTest3Async() + { + return GetTests().ExpandVariableTypesTest3Async(); + } + + [Fact] + public Task ExpandObjectWithNoObjectsTest1Async() + { + return GetTests().ExpandObjectWithNoObjectsTest1Async(); + } + + [Fact] + public Task ExpandObjectWithNoObjectsTest2Async() + { + return GetTests().ExpandObjectWithNoObjectsTest2Async(); + } + + [Fact] + public Task ExpandEmptyEntryTest1Async() + { + return GetTests().ExpandEmptyEntryTest1Async(); + } + + [Fact] + public Task ExpandEmptyEntryTest2Async() + { + return GetTests().ExpandEmptyEntryTest2Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest1Async() + { + return GetTests().ExpandBadNodeIdTest1Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest2Async() + { + return GetTests().ExpandBadNodeIdTest2Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest3Async() + { + return GetTests().ExpandBadNodeIdTest3Async(); + } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/MsgPack/ExpandTests1.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/MsgPack/ExpandTests1.cs new file mode 100644 index 0000000000..4fe7e840ff --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/TestData/MsgPack/ExpandTests1.cs @@ -0,0 +1,178 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Module.Tests.Controller.TestData.MsgPack +{ + using Autofac; + using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures; + using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures; + using Azure.IIoT.OpcUa.Publisher.Testing.Tests; + using System; + using System.Threading.Tasks; + using Xunit; + using Xunit.Abstractions; + + [Collection(ReadCollection.Name)] + public sealed class ExpandTests1 : IClassFixture, IDisposable + { + public ExpandTests1(TestDataServer server, PublisherModuleFixture module, ITestOutputHelper output) + { + _server = server; + _client = module.CreateRestClientContainer(output, TestSerializerType.MsgPack); + } + + public void Dispose() + { + _client.Dispose(); + } + + private ConfigurationTests1 GetTests() + { + return new ConfigurationTests1(_client.Resolve(), + _server.GetConnection()); + } + + private readonly TestDataServer _server; + private readonly IContainer _client; + + [Fact] + public Task ExpandObjectWithBrowsePathTest1Async() + { + return GetTests().ExpandObjectWithBrowsePathTest1Async(); + } + + [Fact] + public Task ExpandObjectWithBrowsePathTest2Async() + { + return GetTests().ExpandObjectWithBrowsePathTest2Async(); + } + + [Fact] + public Task ExpandObjectTest1Async() + { + return GetTests().ExpandObjectTest1Async(); + } + + [Fact] + public Task ExpandObjectTest2Async() + { + return GetTests().ExpandObjectTest2Async(); + } + + [Fact] + public Task ExpandServerObjectTest1Async() + { + return GetTests().ExpandServerObjectTest1Async(); + } + + [Fact] + public Task ExpandServerObjectTest2Async() + { + return GetTests().ExpandServerObjectTest2Async(); + } + + [Fact] + public Task ExpandServerObjectTest3Async() + { + return GetTests().ExpandServerObjectTest3Async(); + } + + [Fact] + public Task ExpandServerObjectTest4Async() + { + return GetTests().ExpandServerObjectTest4Async(); + } + + [Fact] + public Task ExpandServerObjectTest5Async() + { + return GetTests().ExpandServerObjectTest5Async(); + } + + [Fact] + public Task ExpandBaseObjectTypeTest1Async() + { + return GetTests().ExpandBaseObjectTypeTest1Async(); + } + + [Fact] + public Task ExpandBaseObjectsAndObjectTypesTestAsync() + { + return GetTests().ExpandBaseObjectsAndObjectTypesTestAsync(); + } + + [Fact] + public Task ExpandVariablesTest1Async() + { + return GetTests().ExpandVariablesTest1Async(); + } + + [Fact] + public Task ExpandVariablesAndObjectsTest1Async() + { + return GetTests().ExpandVariablesAndObjectsTest1Async(); + } + + [Fact] + public Task ExpandVariableTypesTest1Async() + { + return GetTests().ExpandVariableTypesTest1Async(); + } + + [Fact] + public Task ExpandVariableTypesTest2Async() + { + return GetTests().ExpandVariableTypesTest2Async(); + } + + [Fact] + public Task ExpandVariableTypesTest3Async() + { + return GetTests().ExpandVariableTypesTest3Async(); + } + + [Fact] + public Task ExpandObjectWithNoObjectsTest1Async() + { + return GetTests().ExpandObjectWithNoObjectsTest1Async(); + } + + [Fact] + public Task ExpandObjectWithNoObjectsTest2Async() + { + return GetTests().ExpandObjectWithNoObjectsTest2Async(); + } + + [Fact] + public Task ExpandEmptyEntryTest1Async() + { + return GetTests().ExpandEmptyEntryTest1Async(); + } + + [Fact] + public Task ExpandEmptyEntryTest2Async() + { + return GetTests().ExpandEmptyEntryTest2Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest1Async() + { + return GetTests().ExpandBadNodeIdTest1Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest2Async() + { + return GetTests().ExpandBadNodeIdTest2Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest3Async() + { + return GetTests().ExpandBadNodeIdTest3Async(); + } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModule.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModule.cs index 0e83f69237..c8ad2a1fc3 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModule.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModule.cs @@ -375,6 +375,8 @@ public IContainer CreateClientScope(ITestOutputHelper output, .AsImplementedInterfaces(); builder.RegisterType() .AsImplementedInterfaces(); + builder.RegisterType() + .AsImplementedInterfaces(); switch (serializerType) { diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModuleFixture.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModuleFixture.cs index fff1d67dd7..e80409ab6a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModuleFixture.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModuleFixture.cs @@ -7,7 +7,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures { using Autofac; using System; - using System.IO; using Xunit.Abstractions; public sealed class PublisherModuleMqttv5Fixture : IDisposable diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttConfigurationIntegrationTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttConfigurationIntegrationTests.cs index 935d2e2e50..afcf8c1d3e 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttConfigurationIntegrationTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttConfigurationIntegrationTests.cs @@ -139,7 +139,7 @@ public async Task CanSendPendingConditionsToTopicConfiguredWithMethod(bool useMq Assert.NotNull(result); var messages = await WaitForMessagesAsync(GetAlarmCondition); - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var evt = Assert.Single(messages).Message; Assert.Equal(JsonValueKind.Object, evt.ValueKind); @@ -254,7 +254,7 @@ public async Task CanSendPendingConditionsToTopicConfiguredWithMethod2(bool useM Assert.NotNull(result); var messages = await WaitForMessagesAsync(GetAlarmCondition); - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var evt = Assert.Single(messages).Message; Assert.Equal(JsonValueKind.Object, evt.ValueKind); @@ -287,7 +287,7 @@ public async Task CanSendPendingConditionsToTopicConfiguredWithMethod2(bool useM message => message.GetProperty("DisplayName").GetString() == "SimpleEvents" && message.GetProperty("Value").GetProperty("ReceiveTime").ValueKind == JsonValueKind.String ? message : default); - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var message = Assert.Single(messages); Assert.Equal("i=2253", message.Message.GetProperty("NodeId").GetString()); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttPubSubIntegrationTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttPubSubIntegrationTests.cs index 0d308efdb7..165d57cefc 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttPubSubIntegrationTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttPubSubIntegrationTests.cs @@ -9,6 +9,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Tests.Mqtt.ReferenceServer using Azure.IIoT.OpcUa.Publisher.Module.Tests.Sdk.ReferenceServer; using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures; using Furly.Extensions.Mqtt; + using Json.More; using System; using System.Linq; using System.Text.Json; @@ -293,11 +294,11 @@ public async Task CanSendPendingConditionsToMqttBrokerTest() arguments: new string[] { "--mm=PubSub", "--dm=False" }, version: MqttVersion.v311); // Assert - var evt = Assert.Single(messages); - _output.WriteLine(evt.ToString()); + var message = Assert.Single(messages); + _output.WriteLine(message.Topic + message.Message.ToJsonString()); - Assert.Equal(JsonValueKind.Object, evt.Message.ValueKind); - Assert.True(evt.Message.GetProperty("Payload").GetProperty("Severity").GetProperty("Value").GetInt32() >= 100); + Assert.Equal(JsonValueKind.Object, message.Message.ValueKind); + Assert.True(message.Message.GetProperty("Payload").GetProperty("Severity").GetProperty("Value").GetInt32() >= 100); Assert.NotNull(metadata); } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttUnifiedNamespaceTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttUnifiedNamespaceTests.cs index 62dcde9b82..fb3db70837 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttUnifiedNamespaceTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Mqtt/ReferenceServer/MqttUnifiedNamespaceTests.cs @@ -165,7 +165,7 @@ public async Task CanSendModelChangeEventsToUnifiedNamespace() Assert.NotEmpty(messages); var payload1 = messages[0].Message; - _output.WriteLine(payload1.ToString()); + _output.WriteLine(payload1.ToJsonString()); Assert.NotEqual(JsonValueKind.Null, payload1.ValueKind); Assert.True(Guid.TryParse(payload1.GetProperty("EventId").GetString(), out _)); Assert.Equal("http://www.microsoft.com/opc-publisher#s=ReferenceChange", @@ -177,7 +177,7 @@ public async Task CanSendModelChangeEventsToUnifiedNamespace() Assert.EndsWith("/messages/<>", messages[0].Topic, StringComparison.Ordinal); var payload2 = messages[1].Message; - _output.WriteLine(payload2.ToString()); + _output.WriteLine(payload2.ToJsonString()); Assert.NotEqual(JsonValueKind.Null, payload1.ValueKind); Assert.True(Guid.TryParse(payload2.GetProperty("EventId").GetString(), out _)); Assert.Equal("http://www.microsoft.com/opc-publisher#s=NodeChange", diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/AdvancedPubSubIntegrationTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/AdvancedPubSubIntegrationTests.cs index fd0c18b966..3c477c059b 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/AdvancedPubSubIntegrationTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/AdvancedPubSubIntegrationTests.cs @@ -7,6 +7,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Tests.Sdk.ReferenceServer { using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures; using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures; + using Json.More; using System; using System.Linq; using System.Text.Json; @@ -64,7 +65,7 @@ public async Task SwitchServerWithSameWriterGroupTest() messageType: "ua-data"); message = Assert.Single(messages).Message; - _output.WriteLine(message.ToString()); + _output.WriteLine(message.ToJsonString()); output = message.GetProperty("Messages")[0].GetProperty("Payload").GetProperty("Output"); Assert.NotEqual(JsonValueKind.Null, output.ValueKind); @@ -129,7 +130,7 @@ public async Task SwitchServerWithDifferentWriterGroupTest() predicate: WaitUntilOutput2, messageType: "ua-data"); message = Assert.Single(messages).Message; - _output.WriteLine(message.ToString()); + _output.WriteLine(message.ToJsonString()); output = message.GetProperty("Messages")[0].GetProperty("Payload").GetProperty("Output2"); Assert.NotEqual(JsonValueKind.Null, output.ValueKind); @@ -276,7 +277,7 @@ public async Task SwitchServerWithDifferentDataTest() predicate: WaitUntilOutput2, messageType: "ua-data"); message = Assert.Single(messages).Message; - _output.WriteLine(message.ToString()); + _output.WriteLine(message.ToJsonString()); output = message.GetProperty("Messages")[0].GetProperty("Payload").GetProperty("Output2"); Assert.NotEqual(JsonValueKind.Null, output.ValueKind); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicPubSubIntegrationTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicPubSubIntegrationTests.cs index d9f161e777..472966264d 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicPubSubIntegrationTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicPubSubIntegrationTests.cs @@ -74,7 +74,7 @@ public async Task CanSendModelChangeEventsToIoTHubTest() // Assert Assert.NotEmpty(messages); var payload1 = messages[0].Message.GetProperty("Messages")[0].GetProperty("Payload"); - _output.WriteLine(payload1.ToString()); + _output.WriteLine(payload1.ToJsonString()); Assert.NotEqual(JsonValueKind.Null, payload1.ValueKind); Assert.True(Guid.TryParse(payload1.GetProperty("EventId").GetProperty("Value").GetString(), out _)); Assert.Equal("http://www.microsoft.com/opc-publisher#s=ReferenceChange", @@ -85,7 +85,7 @@ public async Task CanSendModelChangeEventsToIoTHubTest() Assert.Equal("Objects", payload1.GetProperty("Change").GetProperty("Value").GetProperty("DisplayName").GetString()); var payload2 = messages[1].Message.GetProperty("Messages")[0].GetProperty("Payload"); - _output.WriteLine(payload2.ToString()); + _output.WriteLine(payload2.ToJsonString()); Assert.NotEqual(JsonValueKind.Null, payload1.ValueKind); Assert.True(Guid.TryParse(payload2.GetProperty("EventId").GetProperty("Value").GetString(), out _)); Assert.Equal("http://www.microsoft.com/opc-publisher#s=NodeChange", @@ -407,11 +407,11 @@ public async Task CanSendPendingConditionsToIoTHubTest() // Assert Assert.NotEmpty(messages); - var evt = Assert.Single(messages).Message; - _output.WriteLine(evt.ToJsonString()); + var message = Assert.Single(messages).Message; + _output.WriteLine(message.ToJsonString()); - Assert.Equal(JsonValueKind.Object, evt.ValueKind); - Assert.True(evt.GetProperty("Payload").GetProperty("Severity").GetProperty("Value").GetInt32() >= 100); + Assert.Equal(JsonValueKind.Object, message.ValueKind); + Assert.True(message.GetProperty("Payload").GetProperty("Severity").GetProperty("Value").GetInt32() >= 100); Assert.NotNull(metadata); } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicSamplesIntegrationTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicSamplesIntegrationTests.cs index b0267344b7..bef3c60e83 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicSamplesIntegrationTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Sdk/ReferenceServer/BasicSamplesIntegrationTests.cs @@ -143,7 +143,7 @@ public async Task CanSendDeadbandItemsToIoTHubTest() TimeSpan.FromMinutes(2), 20, arguments: new[] { "--fm=True" }); // Assert - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var doubleValues = messages .Where(message => message.Message.GetProperty("DisplayName").GetString() == "DoubleValues" && message.Message.GetProperty("Value").TryGetProperty("Value", out _)); @@ -291,7 +291,7 @@ public async Task CanSendPendingConditionsToIoTHubTest() "./Resources/PendingAlarms.json", GetAlarmCondition); // Assert - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var evt = Assert.Single(messages).Message; Assert.Equal(JsonValueKind.Object, evt.ValueKind); @@ -396,7 +396,7 @@ public async Task CanSendPendingConditionsToIoTHubTestWithDeviceMethod() Assert.NotNull(result); var messages = await WaitForMessagesAsync(GetAlarmCondition); - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var evt = Assert.Single(messages).Message; Assert.Equal(JsonValueKind.Object, evt.ValueKind); @@ -507,7 +507,7 @@ public async Task CanSendPendingConditionsToIoTHubTestWithDeviceMethod2() Assert.NotNull(result); var messages = await WaitForMessagesAsync(GetAlarmCondition); - messages.ToList().ForEach(m => _output.WriteLine(m.ToString())); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var evt = Assert.Single(messages).Message; Assert.Equal(JsonValueKind.Object, evt.ValueKind); @@ -539,7 +539,7 @@ public async Task CanSendPendingConditionsToIoTHubTestWithDeviceMethod2() message => message.GetProperty("DisplayName").GetString() == "SimpleEvents" && message.GetProperty("Value").GetProperty("ReceiveTime").ValueKind == JsonValueKind.String ? message : default); - _output.WriteLine(messages.ToString()); + messages.ForEach(m => _output.WriteLine(m.Topic + m.Message.ToJsonString())); var message = Assert.Single(messages).Message; Assert.Equal("i=2253", message.GetProperty("NodeId").GetString()); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Sdk/src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj b/src/Azure.IIoT.OpcUa.Publisher.Sdk/src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj index 924a23bc95..f0737349f2 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Sdk/src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Sdk/src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj @@ -8,9 +8,9 @@ enable - - - + + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Azure.IIoT.OpcUa.Publisher.Service.Cli.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Azure.IIoT.OpcUa.Publisher.Service.Cli.csproj index ae8abf43c7..e02f50f02d 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Azure.IIoT.OpcUa.Publisher.Service.Cli.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Azure.IIoT.OpcUa.Publisher.Service.Cli.csproj @@ -15,8 +15,9 @@ - + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Program.cs b/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Program.cs index 5c179d2458..4326ac6a20 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Program.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/cli/Program.cs @@ -915,7 +915,7 @@ private async Task GetConfiguredEndpointsAsync(CliOptions options) GetPublisherId(options), new GetConfiguredEndpointsRequestModel { IncludeNodes = options.IsProvidedOrNull("-n", "--nodes") - })) + }).ConfigureAwait(false)) { if (!empty) { diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk.csproj index a2c21b365e..dfc8b518aa 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk/src/Azure.IIoT.OpcUa.Publisher.Service.Sdk.csproj @@ -8,14 +8,14 @@ enable - - - + + + - - - - + + + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj index f6d124d2f4..eefcf93ea0 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi.csproj @@ -16,10 +16,10 @@ - - - - + + + + @@ -28,10 +28,11 @@ - - - + + + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Startup.cs b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Startup.cs index 0f42a6811d..8d7f6e6297 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Startup.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/src/Startup.cs @@ -44,12 +44,12 @@ public class Startup /// /// Service name /// - public string Name => "Opc-Publisher-Service"; + public static string Name => "Opc-Publisher-Service"; /// /// Description /// - public string Description => "Azure Industrial IoT OPC UA Publisher Service"; + public static string Description => "Azure Industrial IoT OPC UA Publisher Service"; /// /// Create startup @@ -70,7 +70,9 @@ public Startup(IConfiguration configuration) /// /// /// +#pragma warning disable CA1822 // Mark members as static public void ConfigureServices(IServiceCollection services) +#pragma warning restore CA1822 // Mark members as static { services.AddLogging(options => options .AddConsole() @@ -126,7 +128,9 @@ public void ConfigureServices(IServiceCollection services) /// /// /// +#pragma warning disable CA1822 // Mark members as static public void Configure(IApplicationBuilder app, IHostApplicationLifetime appLifetime) +#pragma warning restore CA1822 // Mark members as static { app.UsePathBase(); app.UseHeaderForwarding(); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj index 3a2916ab89..06bcb8b6fa 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.csproj @@ -3,9 +3,9 @@ net8.0 - - - + + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Azure.IIoT.OpcUa.Publisher.Service.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Azure.IIoT.OpcUa.Publisher.Service.csproj index 32bda18182..85ae9f7185 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Azure.IIoT.OpcUa.Publisher.Service.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Azure.IIoT.OpcUa.Publisher.Service.csproj @@ -6,7 +6,7 @@ enable - + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Clients/DiscoveryServicesClient.cs b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Clients/DiscoveryServicesClient.cs index 76349d3909..030752a6f3 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Service/src/Clients/DiscoveryServicesClient.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Service/src/Clients/DiscoveryServicesClient.cs @@ -49,7 +49,7 @@ public void Dispose() public async Task CancelAsync(DiscoveryCancelRequestModel request, string? context, CancellationToken ct) { using var activity = _activitySource.StartActivity("CancelDiscovery"); - await foreach (var publisher in EnumeratePublishersAsync(context, ct)) + await foreach (var publisher in EnumeratePublishersAsync(context, ct).ConfigureAwait(false)) { if (publisher.Id == null) { @@ -76,7 +76,7 @@ public async Task FindServerAsync(ServerEndpointQu { using var activity = _activitySource.StartActivity("FindServer"); var exceptions = new List(); - await foreach (var publisher in EnumeratePublishersAsync(context, ct)) + await foreach (var publisher in EnumeratePublishersAsync(context, ct).ConfigureAwait(false)) { if (publisher.Id == null) { @@ -109,7 +109,7 @@ public async Task FindServerAsync(ServerEndpointQu public async Task DiscoverAsync(DiscoveryRequestModel request, string? context, CancellationToken ct) { using var activity = _activitySource.StartActivity("Discover"); - await foreach (var publisher in EnumeratePublishersAsync(context, ct)) + await foreach (var publisher in EnumeratePublishersAsync(context, ct).ConfigureAwait(false)) { if (publisher.Id == null) { @@ -136,7 +136,7 @@ public async Task RegisterAsync(ServerRegistrationRequestModel request, string? CancellationToken ct) { using var activity = _activitySource.StartActivity("RegisterServer"); - await foreach (var publisher in EnumeratePublishersAsync(context, ct)) + await foreach (var publisher in EnumeratePublishersAsync(context, ct).ConfigureAwait(false)) { if (publisher.Id == null) { diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/Azure.IIoT.OpcUa.Publisher.Testing.Servers.csproj b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/Azure.IIoT.OpcUa.Publisher.Testing.Servers.csproj index 5d3dac29e1..a635364539 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/Azure.IIoT.OpcUa.Publisher.Testing.Servers.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/Azure.IIoT.OpcUa.Publisher.Testing.Servers.csproj @@ -55,7 +55,7 @@ - + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs index 19b59d6d36..aea1b18387 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs @@ -43,8 +43,9 @@ public DirectoryObjectState(ISystemContext context, NodeId nodeId, TypeDefinitionId = ObjectTypeIds.FileDirectoryType; SymbolicName = path; NodeId = nodeId; - BrowseName = new QualifiedName(ModelUtils.GetName(path), nodeId.NamespaceIndex); - DisplayName = new LocalizedText(ModelUtils.GetName(path)); + BrowseName = new QualifiedName(isVolume ? path : ModelUtils.GetName(path), + nodeId.NamespaceIndex); + DisplayName = new LocalizedText(isVolume ? path : ModelUtils.GetName(path)); Description = null; WriteMask = 0; UserWriteMask = 0; diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs index bd2b3011c9..ba3c257dac 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs @@ -283,7 +283,7 @@ private ServiceResult OnRead(ISystemContext _context, MethodState _method, } var buffer = new Span(new byte[length]); var read = stream.Read(buffer); - data = buffer.Slice(0, read).ToArray(); + data = buffer[..read].ToArray(); } return result; } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs index e66a317763..f5d382c7c5 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs @@ -92,7 +92,7 @@ public override void CreateAddressSpace(IDictionary> e } // construct the NodeId of a segment. - var fsId = ModelUtils.ConstructIdForVolume(fs.RootDirectory.FullName, _namespaceIndex); + var fsId = ModelUtils.ConstructIdForVolume(fs.Name, _namespaceIndex); // add an organizes reference from the server to the volume. references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, fsId)); @@ -210,7 +210,7 @@ protected override NodeState ValidateNode(ServerSystemContext context, // Validate drive if (parsedNodeId.RootType == ModelUtils.Volume) { - var volume = DriveInfo.GetDrives().FirstOrDefault(d => d.RootDirectory.FullName == parsedNodeId.RootId); + var volume = DriveInfo.GetDrives().FirstOrDefault(d => d.Name == parsedNodeId.RootId); // volume does not exist. if (volume == null) @@ -218,11 +218,11 @@ protected override NodeState ValidateNode(ServerSystemContext context, return null; } - var rootId = ModelUtils.ConstructIdForVolume(volume.RootDirectory.FullName, _namespaceIndex); + var rootId = ModelUtils.ConstructIdForVolume(volume.Name, _namespaceIndex); // create a temporary object to use for the operation. #pragma warning disable CA2000 // Dispose objects before losing scope - root = new DirectoryObjectState(context, rootId, volume.RootDirectory.FullName, true); + root = new DirectoryObjectState(context, rootId, volume.Name, true); #pragma warning restore CA2000 // Dispose objects before losing scope } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj index 979cf9bb6e..14c6cfbe24 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Azure.IIoT.OpcUa.Publisher.Testing.csproj @@ -5,9 +5,9 @@ enable - - - + + + diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/BaseServerFixture.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/BaseServerFixture.cs index cb92e6c7d1..8222a57bb5 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/BaseServerFixture.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/BaseServerFixture.cs @@ -277,7 +277,7 @@ protected virtual void Dispose(bool disposing) _serverHost, pkiPath, sw.Elapsed); // Clean up all created certificates - if (!string.IsNullOrEmpty(pkiPath)) + if (!string.IsNullOrEmpty(pkiPath) && Directory.Exists(pkiPath)) { Try.Op(() => Directory.Delete(pkiPath, true)); } diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/BrowseTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/BrowseTests.cs index f06e31e700..a87c5d15c7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/BrowseTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/BrowseTests.cs @@ -34,18 +34,16 @@ public async Task GetFileSystemsTest1Async(CancellationToken ct = default) { var services = _services(); - var drives = DriveInfo.GetDrives().ToHashSet(); - var found = new HashSet(); - await foreach (var fs in services.GetFileSystemsAsync(_connection, ct)) + var drives = DriveInfo.GetDrives().Select(d => d.Name).ToHashSet(); + await foreach (var fs in services.GetFileSystemsAsync(_connection, ct).ConfigureAwait(false)) { - Assert.NotNull(fs.ErrorInfo); - Assert.Equal(0u, fs.ErrorInfo.StatusCode); + Assert.Null(fs.ErrorInfo); Assert.NotNull(fs.Result); Assert.NotNull(fs.Result.Name); - Assert.Contains(drives, d => d.RootDirectory.FullName == fs.Result?.Name); - found.Add(fs.Result.Name); + Assert.True(drives.Remove(fs.Result.Name), + $"{fs.Result.Name} not found in {string.Join('\n', drives)}"); } - // TODO: Assert.True(drives.Count <= found.Count); + Assert.Empty(drives); } public async Task GetDirectoriesTest1Async(CancellationToken ct = default) diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/OperationsTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/OperationsTests.cs index beed5c4729..1e958de51a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/OperationsTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/OperationsTests.cs @@ -6,7 +6,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Testing.Tests { using Azure.IIoT.OpcUa.Publisher.Models; - using Opc.Ua; using System; using System.Collections.Generic; using System.IO; diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/BrowseStreamTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/BrowseStreamTests.cs index 3375a40f73..19a0dfd32a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/BrowseStreamTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/BrowseStreamTests.cs @@ -767,9 +767,10 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter3Async(Cancellati var results = await browser.BrowseAsync(_connection, new BrowseStreamRequestModel { NodeIds = new[] { "http://test.org/UA/Data/#i=10159" }, - NodeClassFilter = new List { - NodeClass.Method - }, + NodeClassFilter = new List + { + NodeClass.Method + }, Direction = BrowseDirection.Forward }, ct).ToListAsync(cancellationToken: ct).ConfigureAwait(false); @@ -780,7 +781,6 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter3Async(Cancellati Assert.NotNull(node.Attributes); Assert.Null(node.Reference); Assert.Equal("http://test.org/UA/Data/#i=10159", node.SourceId); - Assert.Equal("http://test.org/UA/Data/#i=10159", node.Attributes.NodeId); Assert.Equal("Scalar", node.Attributes.DisplayName); Assert.Equal(NodeClass.Object, node.Attributes.NodeClass); @@ -799,7 +799,6 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter3Async(Cancellati Assert.NotNull(node.Attributes); Assert.Null(node.Reference); Assert.Equal("http://test.org/UA/Data/#i=9385", node.SourceId); - Assert.Equal("http://test.org/UA/Data/#i=9385", node.Attributes.NodeId); Assert.Equal("GenerateValues", node.Attributes.DisplayName); Assert.Equal(NodeClass.Method, node.Attributes.NodeClass); @@ -809,7 +808,6 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter3Async(Cancellati Assert.NotNull(node.Attributes); Assert.Null(node.Reference); Assert.Equal("i=47", node.SourceId); - Assert.Equal("i=47", node.Attributes.NodeId); Assert.Equal("HasComponent", node.Attributes.DisplayName); Assert.Equal(NodeClass.ReferenceType, node.Attributes.NodeClass); @@ -819,7 +817,6 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter3Async(Cancellati Assert.NotNull(node.Attributes); Assert.Null(node.Reference); Assert.Equal("http://test.org/UA/Data/#i=10161", node.SourceId); - Assert.Equal("http://test.org/UA/Data/#i=10161", node.Attributes.NodeId); Assert.Equal("GenerateValues", node.Attributes.DisplayName); Assert.Equal(NodeClass.Method, node.Attributes.NodeClass); @@ -834,9 +831,10 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter4Async(Cancellati var results = await browser.BrowseAsync(_connection, new BrowseStreamRequestModel { NodeIds = new[] { "http://test.org/UA/Data/#i=10159" }, - NodeClassFilter = new List { - NodeClass.Method - }, + NodeClassFilter = new List + { + NodeClass.Method + }, Direction = BrowseDirection.Both }, ct).ToListAsync(cancellationToken: ct).ConfigureAwait(false); @@ -901,10 +899,11 @@ public async Task NodeBrowseStaticScalarVariablesTestWithFilter5Async(Cancellati var results = await browser.BrowseAsync(_connection, new BrowseStreamRequestModel { NodeIds = new[] { "http://test.org/UA/Data/#i=10159" }, - NodeClassFilter = new List { - NodeClass.Method, - NodeClass.Object - } + NodeClassFilter = new List + { + NodeClass.Method, + NodeClass.Object + } }, ct).ToListAsync(cancellationToken: ct).ConfigureAwait(false); Assert.Equal(2409, results.Count); diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests1.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests1.cs new file mode 100644 index 0000000000..2c03cf7caf --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests1.cs @@ -0,0 +1,691 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Testing.Tests +{ + using Azure.IIoT.OpcUa.Publisher.Config.Models; + using Azure.IIoT.OpcUa.Publisher.Models; + using DeterministicAlarms.Configuration; + using Furly.Exceptions; + using Moq; + using Opc.Ua; + using System; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Xunit; + + public class ConfigurationTests1 + { + /// + /// Create configuration tests + /// + /// + /// + public ConfigurationTests1(IConfigurationServices services, ConnectionModel connection) + { + _service = services; + _connection = connection; + } + + public async Task ExpandObjectWithBrowsePathTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157", + BrowsePath = new[] + { + "http://test.org/UA/Data/#Static" + } + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = false, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(12, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandObjectWithBrowsePathTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157", + BrowsePath = new[] + { + "http://test.org/UA/Data/#Static" + } + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = false, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(300, result.Result.OpcNodes.Count); + Assert.All(result.Result.OpcNodes, result => + { + Assert.NotNull(result.DataSetFieldId); + Assert.NotNull(result.Id); + }); + } + + public async Task ExpandObjectTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(25, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandObjectTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = true, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(623, result.Result.OpcNodes.Count); + } + + public async Task ExpandServerObjectTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = true, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(73, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandServerObjectTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(74, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandServerObjectTest3Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(920, result.Result.OpcNodes.Count); + } + + public async Task ExpandServerObjectTest4Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + MaxDepth = 1, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(8, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandServerObjectTest5Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + MaxDepth = 0, + MaxLevelsToExpand = 1, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(7, result.Result.OpcNodes.Count); + } + + public async Task ExpandBaseObjectTypeTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectTypeIds.BaseObjectType.ToString() + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(77, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandBaseObjectsAndObjectTypesTestAsync(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectTypeIds.BaseObjectType.ToString(), + DataSetFieldId = "type" + }, + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString(), + DataSetFieldId = "object" + }, + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157", + DataSetFieldId = "data" + } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(77 + 74 + 25, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandVariablesTest1Async(CancellationToken ct = default) + { + // Test only variables as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10216" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10217" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10218" } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(6, result.Result.OpcNodes.Count); + } + + public async Task ExpandVariablesAndObjectsTest1Async(CancellationToken ct = default) + { + // Test mixing variables and objects in an entry to expand + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10217" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10216" }, + new OpcNodeModel { Id = Opc.Ua.ObjectIds.Server.ToString() }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10218" }, + new OpcNodeModel { Id = Opc.Ua.ObjectTypeIds.BaseObjectType.ToString() } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(1 + 77 + 74, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + } + + public async Task ExpandVariableTypesTest1Async(CancellationToken ct = default) + { + // Test only variable types as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.PropertyType.ToString() } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.Equal(Opc.Ua.VariableTypeIds.PropertyType + "/Variables", result.Result.DataSetWriterId); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(675, result.Result.OpcNodes.Count); + } + + public async Task ExpandVariableTypesTest2Async(CancellationToken ct = default) + { + // Test only variable types as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.DataItemType.ToString() } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.Equal(Opc.Ua.VariableTypeIds.DataItemType + "/Variables", result.Result.DataSetWriterId); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(96, result.Result.OpcNodes.Count); + } + + public async Task ExpandVariableTypesTest3Async(CancellationToken ct = default) + { + // Test only variable types as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.PropertyType.ToString() }, + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.DataItemType.ToString() } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(2, results.Count); + var total = 0; + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.EndsWith("/Variables", r.Result.DataSetWriterId, StringComparison.InvariantCulture); + total += r.Result.OpcNodes.Count; + }); + Assert.Equal(96 + 675, total); + } + + public async Task ExpandObjectWithNoObjectsTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10791" } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + // An object node that has no objects and is excluded should result + // in a service result model with "No objects resolved" + var result = Assert.Single(results); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal("http://test.org/UA/Data/#i=10791", + Assert.Single(result.Result.OpcNodes).Id); + Assert.NotNull(result.ErrorInfo); + Assert.Equal("No objects resolved.", result.ErrorInfo.ErrorMessage); + Assert.Equal(StatusCodes.BadNotFound, result.ErrorInfo.StatusCode); + } + + public async Task ExpandObjectWithNoObjectsTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10791" } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = true, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + + }, ct).ToListAsync(ct).ConfigureAwait(false); + + // Discard errors -> no errors + Assert.Empty(results); + } + + public async Task ExpandEmptyEntryTest1Async(CancellationToken ct = default) + { + // Nothing should be returned when an empty entry passed + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = Array.Empty(); + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Empty(results); + } + + public async Task ExpandEmptyEntryTest2Async(CancellationToken ct = default) + { + // Nothing should be returned when an empty entry passed + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = Array.Empty(); + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Empty(results); + } + + public async Task ExpandBadNodeIdTest1Async(CancellationToken ct = default) + { + // Entry with bad node id should return error + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "s=bad" } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal("s=bad", Assert.Single(result.Result.OpcNodes).Id); + Assert.NotNull(result.ErrorInfo); + Assert.Equal(StatusCodes.BadNodeIdUnknown, result.ErrorInfo.StatusCode); + } + + public async Task ExpandBadNodeIdTest2Async(CancellationToken ct = default) + { + // An entry with multiple nodes and duplicate field ids should return an error + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10157", DataSetFieldId = "test" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10158", DataSetFieldId = "test" } + }; + var ex = await Assert.ThrowsAnyAsync(async () => await _service.ExpandAsync( + entry, new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false)).ConfigureAwait(false); + + Assert.True(ex is MethodCallStatusException or BadRequestException); + } + + public async Task ExpandBadNodeIdTest3Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + // Method or view node id passed results in not supported error. + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = MethodIds.Server_GetMonitoredItems.ToString() } + }; + var results = await _service.ExpandAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = false, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.NotNull(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(MethodIds.Server_GetMonitoredItems.ToString(), Assert.Single(result.Result.OpcNodes).Id); + Assert.Equal(StatusCodes.BadNotSupported, result.ErrorInfo.StatusCode); + } + + // + // 7. Object and maxdepth 0 -> max depth 1 + // 7b.Object and stop first found -> organizes is used + // 8. Object type with stop first found set uses organizes + // + + private readonly ConnectionModel _connection; + private readonly IConfigurationServices _service; + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests2.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests2.cs new file mode 100644 index 0000000000..3efbc0ebe3 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/TestData/ConfigurationTests2.cs @@ -0,0 +1,870 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Testing.Tests +{ + using Azure.IIoT.OpcUa.Publisher.Config.Models; + using Azure.IIoT.OpcUa.Publisher.Models; + using DeterministicAlarms.Configuration; + using Furly.Exceptions; + using Moq; + using Moq.Language.Flow; + using Opc.Ua; + using System; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + using Xunit; + + public class ConfigurationTests2 + { + /// + /// Create configuration tests + /// + /// + /// + public ConfigurationTests2(Func services, + ConnectionModel connection) + { + _service= services; + _connection = connection; + _publishedNodesServices = new Mock(); + _createCall = _publishedNodesServices.Setup(s => s.CreateOrUpdateDataSetWriterEntryAsync( + It.IsAny(), It.IsAny())) + .Returns(Task.CompletedTask); + } + + public async Task ConfigureFromObjectErrorTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + + // Save error will return error for entry + var createCall = _publishedNodesServices.Setup(s => s.CreateOrUpdateDataSetWriterEntryAsync( + It.IsAny(), It.IsAny())) + .Throws(); + createCall.Verifiable(Times.Exactly(25)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(25, results.Count); + Assert.All(results, r => + { + Assert.NotNull(r.ErrorInfo); + Assert.Equal(StatusCodes.BadInvalidArgument, r.ErrorInfo.StatusCode); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectErrorTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + + // Save error will return error for entry + var createCall = _publishedNodesServices.Setup(s => s.CreateOrUpdateDataSetWriterEntryAsync( + It.IsAny(), It.IsAny())) + .Throws(); + createCall.Verifiable(Times.Exactly(25)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = true, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Empty(results); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectErrorTest3Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + + // Save error will return error for entry + var createCall = _publishedNodesServices.Setup(s => s.CreateOrUpdateDataSetWriterEntryAsync( + It.IsAny(), It.IsAny())) + .Throws(); + createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + + Assert.NotNull(result.ErrorInfo); + Assert.Equal(StatusCodes.BadInvalidArgument, result.ErrorInfo.StatusCode); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(623, result.Result.OpcNodes.Count); + + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectWithBrowsePathTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157", + BrowsePath = new[] + { + "http://test.org/UA/Data/#Static" + } + } + }; + _createCall.Verifiable(Times.Exactly(12)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = false, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(12, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectWithBrowsePathTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157", + BrowsePath = new[] + { + "http://test.org/UA/Data/#Static" + } + } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = false, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(300, result.Result.OpcNodes.Count); + Assert.All(result.Result.OpcNodes, result => + { + Assert.NotNull(result.DataSetFieldId); + Assert.NotNull(result.Id); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + _createCall.Verifiable(Times.Exactly(25)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(25, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157" + } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = true, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(623, result.Result.OpcNodes.Count); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromServerObjectTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + _createCall.Verifiable(Times.Exactly(73)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = true, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(73, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromServerObjectTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + _createCall.Verifiable(Times.Exactly(74)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(74, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromServerObjectTest3Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(920, result.Result.OpcNodes.Count); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromServerObjectTest4Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + _createCall.Verifiable(Times.Exactly(8)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + MaxDepth = 1, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(8, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromServerObjectTest5Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString() + } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + MaxDepth = 0, + MaxLevelsToExpand = 1, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(7, result.Result.OpcNodes.Count); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromBaseObjectTypeTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectTypeIds.BaseObjectType.ToString() + } + }; + _createCall.Verifiable(Times.Exactly(77)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(77, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromBaseObjectsAndObjectTypesTestAsync(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel + { + Id = Opc.Ua.ObjectTypeIds.BaseObjectType.ToString(), + DataSetFieldId = "type" + }, + new OpcNodeModel + { + Id = Opc.Ua.ObjectIds.Server.ToString(), + DataSetFieldId = "object" + }, + new OpcNodeModel + { + Id = "http://test.org/UA/Data/#i=10157", + DataSetFieldId = "data" + } + }; + _createCall.Verifiable(Times.Exactly(77 + 74 + 25)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(77 + 74 + 25, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromVariablesTest1Async(CancellationToken ct = default) + { + // Test only variables as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10216" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10217" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10218" } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(6, result.Result.OpcNodes.Count); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromVariablesAndObjectsTest1Async(CancellationToken ct = default) + { + // Test mixing variables and objects in an entry to expand + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10217" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10216" }, + new OpcNodeModel { Id = Opc.Ua.ObjectIds.Server.ToString() }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10218" }, + new OpcNodeModel { Id = Opc.Ua.ObjectTypeIds.BaseObjectType.ToString() } + }; + _createCall.Verifiable(Times.Exactly(1 + 77 + 74)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(1 + 77 + 74, results.Count); + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.True(r.Result.OpcNodes.Count > 0); + }); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromVariableTypesTest1Async(CancellationToken ct = default) + { + // Test only variable types as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.PropertyType.ToString() } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.Equal(Opc.Ua.VariableTypeIds.PropertyType + "/Variables", result.Result.DataSetWriterId); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(675, result.Result.OpcNodes.Count); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromVariableTypesTest2Async(CancellationToken ct = default) + { + // Test only variable types as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.DataItemType.ToString() } + }; + _createCall.Verifiable(Times.Once); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.Null(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.Equal(Opc.Ua.VariableTypeIds.DataItemType + "/Variables", result.Result.DataSetWriterId); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(96, result.Result.OpcNodes.Count); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromVariableTypesTest3Async(CancellationToken ct = default) + { + // Test only variable types as node ids in an entry + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.PropertyType.ToString() }, + new OpcNodeModel { Id = Opc.Ua.VariableTypeIds.DataItemType.ToString() } + }; + _createCall.Verifiable(Times.Exactly(2)); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + StopAtFirstFoundInstance = false, + ExcludeRootIfInstanceNode = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Equal(2, results.Count); + var total = 0; + Assert.All(results, r => + { + Assert.Null(r.ErrorInfo); + Assert.NotNull(r.Result); + Assert.NotNull(r.Result.OpcNodes); + Assert.EndsWith("/Variables", r.Result.DataSetWriterId, StringComparison.InvariantCulture); + total += r.Result.OpcNodes.Count; + }); + Assert.Equal(96 + 675, total); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectWithNoObjectsTest1Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10791" } + }; + _createCall.Verifiable(Times.Never); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + // An object node that has no objects and is excluded should result + // in a service result model with "No objects resolved" + var result = Assert.Single(results); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal("http://test.org/UA/Data/#i=10791", + Assert.Single(result.Result.OpcNodes).Id); + Assert.NotNull(result.ErrorInfo); + Assert.Equal("No objects resolved.", result.ErrorInfo.ErrorMessage); + Assert.Equal(StatusCodes.BadNotFound, result.ErrorInfo.StatusCode); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromObjectWithNoObjectsTest2Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10791" } + }; + _createCall.Verifiable(Times.Never); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = true, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + + }, ct).ToListAsync(ct).ConfigureAwait(false); + + // Discard errors -> no errors + Assert.Empty(results); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromEmptyEntryTest1Async(CancellationToken ct = default) + { + // Nothing should be returned when an empty entry passed + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = Array.Empty(); + _createCall.Verifiable(Times.Never); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Empty(results); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromEmptyEntryTest2Async(CancellationToken ct = default) + { + // Nothing should be returned when an empty entry passed + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = Array.Empty(); + _createCall.Verifiable(Times.Never); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = true + }, ct).ToListAsync(ct).ConfigureAwait(false); + + Assert.Empty(results); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromBadNodeIdTest1Async(CancellationToken ct = default) + { + // Entry with bad node id should return error + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "s=bad" } + }; + _createCall.Verifiable(Times.Never); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal("s=bad", Assert.Single(result.Result.OpcNodes).Id); + Assert.NotNull(result.ErrorInfo); + Assert.Equal(StatusCodes.BadNodeIdUnknown, result.ErrorInfo.StatusCode); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromBadNodeIdTest2Async(CancellationToken ct = default) + { + // An entry with multiple nodes and duplicate field ids should return an error + var entry = _connection.ToPublishedNodesEntry(); + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10157", DataSetFieldId = "test" }, + new OpcNodeModel { Id = "http://test.org/UA/Data/#i=10158", DataSetFieldId = "test" } + }; + _createCall.Verifiable(Times.Never); + var ex = await Assert.ThrowsAnyAsync(async () => await _service(_publishedNodesServices.Object).CreateOrUpdateAsync( + entry, new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = true, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false)).ConfigureAwait(false); + + Assert.True(ex is MethodCallStatusException or BadRequestException); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + public async Task ConfigureFromBadNodeIdTest3Async(CancellationToken ct = default) + { + var entry = _connection.ToPublishedNodesEntry(); + // Method or view node id passed results in not supported error. + entry.OpcNodes = new[] + { + new OpcNodeModel { Id = MethodIds.Server_GetMonitoredItems.ToString() } + }; + _createCall.Verifiable(Times.Never); + var results = await _service(_publishedNodesServices.Object).CreateOrUpdateAsync(entry, + new PublishedNodeExpansionModel + { + DiscardErrors = false, + ExcludeRootIfInstanceNode = false, + StopAtFirstFoundInstance = false, + NoSubTypesOfTypeNodes = false, + CreateSingleWriter = false + }, ct).ToListAsync(ct).ConfigureAwait(false); + + var result = Assert.Single(results); + Assert.NotNull(result.ErrorInfo); + Assert.NotNull(result.Result); + Assert.NotNull(result.Result.OpcNodes); + Assert.Equal(MethodIds.Server_GetMonitoredItems.ToString(), Assert.Single(result.Result.OpcNodes).Id); + Assert.Equal(StatusCodes.BadNotSupported, result.ErrorInfo.StatusCode); + _publishedNodesServices.Verify(); + _publishedNodesServices.VerifyNoOtherCalls(); + } + + private readonly ConnectionModel _connection; + private readonly Mock _publishedNodesServices; + private readonly IReturnsResult _createCall; + private readonly Func _service; + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj b/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj index b245789604..ea7f365ce9 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Azure.IIoT.OpcUa.Publisher.csproj @@ -7,9 +7,9 @@ - + - + diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/NetworkDiscovery.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/NetworkDiscovery.cs index 2e85df5c9f..e6ccb8a8a7 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/NetworkDiscovery.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/NetworkDiscovery.cs @@ -253,7 +253,7 @@ private async Task ProcessDiscoveryRequestsAsync(CancellationToken ct) { _logger.LogInformation("Starting discovery processor..."); // Process all discovery requests - await foreach (var request in _channel.Reader.ReadAllAsync(ct)) + await foreach (var request in _channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) { try { diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/ProgressPublisher.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/ProgressPublisher.cs index 0a9aa80890..bb08c5a402 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/ProgressPublisher.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Discovery/ProgressPublisher.cs @@ -76,7 +76,7 @@ public void Dispose() /// private async Task SendProgressAsync() { - await foreach (var progress in _channel.Reader.ReadAllAsync()) + await foreach (var progress in _channel.Reader.ReadAllAsync().ConfigureAwait(false)) { try { diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/ContainerBuilderEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/ContainerBuilderEx.cs index a12881ac60..1b55b43d85 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/ContainerBuilderEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/ContainerBuilderEx.cs @@ -34,7 +34,7 @@ public static void AddPublisherCore(this ContainerBuilder builder) .AsImplementedInterfaces().SingleInstance(); builder.RegisterType() .SingleInstance(); - builder.RegisterType() + builder.RegisterType() .AsImplementedInterfaces().SingleInstance(); builder.RegisterType() .AsImplementedInterfaces().SingleInstance(); @@ -47,8 +47,12 @@ public static void AddPublisherCore(this ContainerBuilder builder) .AsImplementedInterfaces().SingleInstance(); builder.RegisterType>() .AsImplementedInterfaces().InstancePerLifetimeScope(); + builder.RegisterType() + .AsImplementedInterfaces().InstancePerLifetimeScope(); builder.RegisterType>() .AsImplementedInterfaces().InstancePerLifetimeScope(); + builder.RegisterType>() + .AsImplementedInterfaces().InstancePerLifetimeScope(); builder.RegisterType() .AsImplementedInterfaces().InstancePerLifetimeScope(); builder.RegisterType() diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/RequestHeaderModelEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/RequestHeaderModelEx.cs new file mode 100644 index 0000000000..41596eed7e --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/RequestHeaderModelEx.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Models +{ + using Azure.IIoT.OpcUa.Publisher; + using Azure.IIoT.OpcUa.Publisher.Parser; + using Microsoft.Extensions.Options; + using Opc.Ua; + using Opc.Ua.Extensions; + + /// + /// Helpers for request header + /// + public static class RequestHeaderModelEx + { + /// + /// Get namespace format for the response + /// + /// + /// + /// + public static NamespaceFormat GetNamespaceFormat(this RequestHeaderModel? header, + IOptions? options = null) + { + return header?.NamespaceFormat ?? options?.Value.DefaultNamespaceFormat + ?? NamespaceFormat.Uri; + } + + /// + /// Convert to string based on request header + /// + /// + /// + /// + /// + /// + public static string AsString(this RequestHeaderModel? header, NodeId nodeId, + IServiceMessageContext context, IOptions? options = null) + { + return nodeId.AsString(context, header.GetNamespaceFormat(options)) ?? string.Empty; + } + + /// + /// Convert to string based on request header + /// + /// + /// + /// + /// + /// + public static string AsString(this RequestHeaderModel? header, ExpandedNodeId nodeId, + IServiceMessageContext context, IOptions? options = null) + { + return nodeId.AsString(context, header.GetNamespaceFormat(options)) ?? string.Empty; + } + + /// + /// Convert to string based on request header + /// + /// + /// + /// + /// + /// + public static string AsString(this RequestHeaderModel? header, QualifiedName qualifiedName, + IServiceMessageContext context, IOptions? options = null) + { + return qualifiedName.AsString(context, header.GetNamespaceFormat(options)) ?? string.Empty; + } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/IConfigurationServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/IConfigurationServices.cs index 3e40907880..d8ca44daf2 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/IConfigurationServices.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/IConfigurationServices.cs @@ -8,177 +8,37 @@ namespace Azure.IIoT.OpcUa.Publisher using Azure.IIoT.OpcUa.Publisher.Models; using System.Collections.Generic; using System.Threading; - using System.Threading.Tasks; /// - /// Enables remote configuration of the publisher + /// Configuration services using node services to explore and expand + /// requests based on the server address space. /// - public interface IConfigurationServices : IPublishServices + public interface IConfigurationServices { /// - /// Create a published nodes entry for a specific writer group - /// and dataset writer. The entry must specify a unique writer - /// group and dataset writer id. If the entry is found it is - /// updated, if it is not found, it is created. If more than - /// one entry is found an error is returned. The entry can - /// include nodes which will be the initial set. The entries - /// must all specify a unique dataSetFieldId. - /// - /// The entry to create for the writer - /// - /// - Task CreateOrUpdateDataSetWriterEntryAsync( - PublishedNodesEntryModel entry, CancellationToken ct = default); - - /// - /// Get the published nodes entry for a specific writer group - /// and dataset writer. Dedicated errors are returned if no, - /// or no unique entry could be found. THe entry does not - /// contain the nodes - /// - /// The writer group - /// The data set writer - /// - /// - Task GetDataSetWriterEntryAsync( - string writerGroupId, string dataSetWriterId, - CancellationToken ct = default); - - /// - /// Add Nodes to a dedicated data set writer in a writer group. - /// Each node must have a unique DataSetFieldId. If the field - /// already exists, the node is updated. If a node does not - /// have a dataset field id an error is returned. - /// - /// The writer group - /// The data set writer - /// Nodes to add or update - /// Field after which to - /// insert the nodes. If not specified, nodes are added at the - /// end of the entry - /// - /// - Task AddOrUpdateNodesAsync(string writerGroupId, string dataSetWriterId, - IReadOnlyList nodes, string? insertAfterFieldId = null, - CancellationToken ct = default); - - /// - /// Remove Nodes with the data set field ids from a data set - /// writer in a writer group. If the field is not found, no - /// error is returned. - /// - /// The writer group - /// The data set writer - /// Fields to add - /// - /// - Task RemoveNodesAsync(string writerGroupId, string dataSetWriterId, - IReadOnlyList dataSetFieldIds, CancellationToken ct = default); - - /// - /// Get Nodes from a data set writer in a writer group. - /// - /// The writer group - /// The data set writer - /// the field id after which to start. - /// If not specified, nodes from the beginning are returned. - /// Number of nodes to return - /// - /// - Task> GetNodesAsync(string writerGroupId, - string dataSetWriterId, string? dataSetFieldId = null, - int? count = null, CancellationToken ct = default); - - /// - /// Get a node from a data set writer in a writer group. - /// - /// The writer group - /// The data set writer - /// the field id after which to start. - /// If not specified, nodes from the beginning are returned. - /// - /// - Task GetNodeAsync(string writerGroupId, - string dataSetWriterId, string dataSetFieldId, - CancellationToken ct = default); - - /// - /// Remove the published nodes entry for a specific data set - /// writer in a writer group. Dedicated errors are returned if no, - /// or no unique entry could be found. - /// - /// The writer group - /// The data set writer - /// Force delete all writers even if more than - /// one were found. Does not error when none were found. - /// - /// - Task RemoveDataSetWriterEntryAsync(string writerGroupId, - string dataSetWriterId, bool force = false, - CancellationToken ct = default); - - /// - /// Add nodes to be published to the configuration - /// - /// - /// - Task PublishNodesAsync(PublishedNodesEntryModel request, - CancellationToken ct = default); - - /// - /// Remove node from the actual configuration - /// - /// - /// - Task UnpublishNodesAsync(PublishedNodesEntryModel request, - CancellationToken ct = default); - - /// - /// Resets the configuration for an endpoint - /// - /// - /// - Task UnpublishAllNodesAsync(PublishedNodesEntryModel request, - CancellationToken ct = default); - - /// - /// Replace all configured endpoints with the new set. - /// Using an empty list will remove all entries. + /// Expand the provided entry into configuration entries and return them + /// one by one with the error items last. The configuration is not updated but + /// the resulting entries without error info can be added in a later call to + /// the publisher configuration api. /// + /// /// /// - Task SetConfiguredEndpointsAsync(IReadOnlyList request, - CancellationToken ct = default); - - /// - /// Update nodes of endpoints in the published nodes configuration. - /// - /// - /// - Task AddOrUpdateEndpointsAsync(IReadOnlyList request, + /// + IAsyncEnumerable> ExpandAsync( + PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, CancellationToken ct = default); /// - /// returns the endpoints currently part of the configuration - /// - /// - /// - Task> GetConfiguredEndpointsAsync( - bool includeNodes = false, CancellationToken ct = default); - - /// - /// Get the configuration nodes for an endpoint + /// Create one or more writer entries by expanding the provided entry into + /// the configuration. The expanded items are added to the configuration. /// + /// /// /// - Task> GetConfiguredNodesOnEndpointAsync( - PublishedNodesEntryModel request, CancellationToken ct = default); - - /// - /// Gets the diagnostic information for a specific endpoint - /// - /// - Task> GetDiagnosticInfoAsync( + /// + IAsyncEnumerable> CreateOrUpdateAsync( + PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, CancellationToken ct = default); } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/INodeServicesInternal.cs b/src/Azure.IIoT.OpcUa.Publisher/src/INodeServicesInternal.cs index 98ee9ded09..563f9ba63d 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/INodeServicesInternal.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/INodeServicesInternal.cs @@ -64,12 +64,5 @@ Task HistoryUpdateAsync( T connectionId, HistoryUpdateRequestModel request, Func> decode, CancellationToken ct = default) where TInput : class; - - /// - /// Get namespace format from header or underlying configuration. - /// - /// - /// - NamespaceFormat GetNamespaceFormat(RequestHeaderModel? header); } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/IPublishedNodesServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/IPublishedNodesServices.cs new file mode 100644 index 0000000000..55f1a1dad3 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/IPublishedNodesServices.cs @@ -0,0 +1,184 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher +{ + using Azure.IIoT.OpcUa.Publisher.Models; + using System.Collections.Generic; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Enables remote configuration of the publisher + /// + public interface IPublishedNodesServices : IPublishServices + { + /// + /// Create a published nodes entry for a specific writer group + /// and dataset writer. The entry must specify a unique writer + /// group and dataset writer id. If the entry is found it is + /// replaced, if it is not found, it is created. If more than + /// one entry is found an error is returned. The entry can + /// include nodes which will be the initial set. The entries + /// must all specify a unique dataSetFieldId. + /// + /// The entry to create for the writer + /// + /// + Task CreateOrUpdateDataSetWriterEntryAsync( + PublishedNodesEntryModel entry, CancellationToken ct = default); + + /// + /// Get the published nodes entry for a specific writer group + /// and dataset writer. Dedicated errors are returned if no, + /// or no unique entry could be found. THe entry does not + /// contain the nodes + /// + /// The writer group + /// The data set writer + /// + /// + Task GetDataSetWriterEntryAsync( + string writerGroupId, string dataSetWriterId, + CancellationToken ct = default); + + /// + /// Add Nodes to a dedicated data set writer in a writer group. + /// Each node must have a unique DataSetFieldId. If the field + /// already exists, the node is updated. If a node does not + /// have a dataset field id an error is returned. + /// + /// The writer group + /// The data set writer + /// Nodes to add or update + /// Field after which to + /// insert the nodes. If not specified, nodes are added at the + /// end of the entry + /// + /// + Task AddOrUpdateNodesAsync(string writerGroupId, string dataSetWriterId, + IReadOnlyList nodes, string? insertAfterFieldId = null, + CancellationToken ct = default); + + /// + /// Remove Nodes with the data set field ids from a data set + /// writer in a writer group. If the field is not found, no + /// error is returned. + /// + /// The writer group + /// The data set writer + /// Fields to add + /// + /// + Task RemoveNodesAsync(string writerGroupId, string dataSetWriterId, + IReadOnlyList dataSetFieldIds, CancellationToken ct = default); + + /// + /// Get Nodes from a data set writer in a writer group. + /// + /// The writer group + /// The data set writer + /// the field id after which to start. + /// If not specified, nodes from the beginning are returned. + /// Number of nodes to return + /// + /// + Task> GetNodesAsync(string writerGroupId, + string dataSetWriterId, string? dataSetFieldId = null, + int? count = null, CancellationToken ct = default); + + /// + /// Get a node from a data set writer in a writer group. + /// + /// The writer group + /// The data set writer + /// the field id after which to start. + /// If not specified, nodes from the beginning are returned. + /// + /// + Task GetNodeAsync(string writerGroupId, + string dataSetWriterId, string dataSetFieldId, + CancellationToken ct = default); + + /// + /// Remove the published nodes entry for a specific data set + /// writer in a writer group. Dedicated errors are returned if no, + /// or no unique entry could be found. + /// + /// The writer group + /// The data set writer + /// Force delete all writers even if more than + /// one were found. Does not error when none were found. + /// + /// + Task RemoveDataSetWriterEntryAsync(string writerGroupId, + string dataSetWriterId, bool force = false, + CancellationToken ct = default); + + /// + /// Add nodes to be published to the configuration + /// + /// + /// + Task PublishNodesAsync(PublishedNodesEntryModel request, + CancellationToken ct = default); + + /// + /// Remove node from the actual configuration + /// + /// + /// + Task UnpublishNodesAsync(PublishedNodesEntryModel request, + CancellationToken ct = default); + + /// + /// Resets the configuration for an endpoint + /// + /// + /// + Task UnpublishAllNodesAsync(PublishedNodesEntryModel request, + CancellationToken ct = default); + + /// + /// Replace all configured endpoints with the new set. + /// Using an empty list will remove all entries. + /// + /// + /// + Task SetConfiguredEndpointsAsync(IReadOnlyList request, + CancellationToken ct = default); + + /// + /// Update nodes of endpoints in the published nodes configuration. + /// + /// + /// + Task AddOrUpdateEndpointsAsync(IReadOnlyList request, + CancellationToken ct = default); + + /// + /// returns the endpoints currently part of the configuration + /// + /// + /// + Task> GetConfiguredEndpointsAsync( + bool includeNodes = false, CancellationToken ct = default); + + /// + /// Get the configuration nodes for an endpoint + /// + /// + /// + Task> GetConfiguredNodesOnEndpointAsync( + PublishedNodesEntryModel request, CancellationToken ct = default); + + /// + /// Gets the diagnostic information for a specific endpoint + /// + /// + Task> GetDiagnosticInfoAsync( + CancellationToken ct = default); + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/AsyncEnumerableBrowser.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/AsyncEnumerableBrowser.cs new file mode 100644 index 0000000000..cc5ce570a8 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/AsyncEnumerableBrowser.cs @@ -0,0 +1,432 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Services +{ + using Azure.IIoT.OpcUa.Publisher.Models; + using Azure.IIoT.OpcUa.Publisher.Stack; + using Azure.IIoT.OpcUa.Publisher.Stack.Models; + using Microsoft.Extensions.Options; + using Opc.Ua; + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Threading.Tasks; + + /// + /// Async enumerable browsing operation base class. Used in configuration + /// and file system browse operations. + /// + /// + internal abstract class AsyncEnumerableBrowser : AsyncEnumerableEnumerableStack + where T : class + { + protected TimeProvider TimeProvider { get; } + protected RequestHeaderModel? Header { get; } + protected IOptions Options { get; } + + /// + /// Create browser + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + protected AsyncEnumerableBrowser(RequestHeaderModel? header, + IOptions options, TimeProvider? timeProvider = null, + NodeId? root = null, NodeId? typeDefinitionId = null, + bool includeTypeDefinitionSubtypes = true, bool stopWhenFound = false, + uint? maxDepth = null, Opc.Ua.NodeClass nodeClass = Opc.Ua.NodeClass.Object, + NodeId? referenceTypeId = null, bool includeReferenceTypeSubtypes = true, + Opc.Ua.NodeClass? matchClass = null) + { + Header = header; + Options = options; + TimeProvider = timeProvider ?? TimeProvider.System; + + _root = null!; + _referenceTypeId = null!; + + Initialize(maxDepth, root, nodeClass, typeDefinitionId, + includeTypeDefinitionSubtypes, referenceTypeId, + includeReferenceTypeSubtypes, stopWhenFound, matchClass); + } + + /// + public override void Dispose() + { + _activitySource.Dispose(); + } + + /// + public override void Reset() + { + base.Reset(); + OnReset(); + } + + /// + /// Override to disable starting browsing + /// + protected virtual void OnReset() + { + Start(); + } + + /// + /// Restart browsing with different configuration + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + protected void Restart(NodeId? root = null, uint? maxDepth = null, + NodeId? typeDefinitionId = null, bool includeTypeDefinitionSubtypes = true, + bool stopWhenFound = false, + NodeId? referenceTypeId = null, bool includeReferenceTypeSubtypes = true, + Opc.Ua.NodeClass nodeClass = Opc.Ua.NodeClass.Object, + Opc.Ua.NodeClass? matchClass = null) + { + Initialize(maxDepth, root, nodeClass, typeDefinitionId, + includeTypeDefinitionSubtypes, referenceTypeId, + includeReferenceTypeSubtypes, stopWhenFound, matchClass); + Start(); + } + + /// + /// Handle error + /// + /// + /// + /// + protected abstract IEnumerable HandleError( + ServiceCallContext context, ServiceResultModel errorInfo); + + /// + /// handle matches + /// + /// + /// + /// + protected abstract IEnumerable HandleMatching( + ServiceCallContext context, IReadOnlyList matching); + + /// + /// Handle browse completion + /// + /// + /// + protected virtual IEnumerable HandleCompletion(ServiceCallContext context) + { + return Enumerable.Empty(); + } + + /// + /// Browse references + /// + /// + /// + private async ValueTask> BrowseAsync(ServiceCallContext context) + { + using var trace = _activitySource.StartActivity("Browse"); + + var frame = Pop(); + if (frame == null) + { + return HandleCompletion(context); + } + + var browseDescriptions = new BrowseDescriptionCollection + { + new BrowseDescription + { + BrowseDirection = Opc.Ua.BrowseDirection.Forward, + NodeClassMask = (uint)_nodeClass | (uint)_matchClass, + NodeId = frame.NodeId, + ReferenceTypeId = _referenceTypeId, + IncludeSubtypes = _includeReferenceTypeSubtypes, + ResultMask = (uint)BrowseResultMask.All + } + }; + + // Browse and read children + var response = await context.Session.Services.BrowseAsync( + Header.ToRequestHeader(TimeProvider), null, 0, + browseDescriptions, context.Ct).ConfigureAwait(false); + + var results = response.Validate(response.Results, r => r.StatusCode, + response.DiagnosticInfos, browseDescriptions); + if (results.ErrorInfo != null) + { + return HandleError(context, results.ErrorInfo); + } + var refs = MatchReferences(frame, context, results[0].Result.References, + results[0].ErrorInfo); + var continuation = results[0].Result.ContinuationPoint ?? Array.Empty(); + if (continuation.Length > 0) + { + Push(context => BrowseNextAsync(context, continuation, frame)); + } + else + { + Push(context => BrowseAsync(context)); + } + return refs; + } + + /// + /// Browse remainder of references + /// + /// + /// + /// + /// + private async ValueTask> BrowseNextAsync(ServiceCallContext context, + byte[] continuationPoint, BrowseFrame frame) + { + using var trace = _activitySource.StartActivity("BrowseNext"); + + var continuationPoints = new ByteStringCollection { continuationPoint }; + var response = await context.Session.Services.BrowseNextAsync( + Header.ToRequestHeader(TimeProvider), false, continuationPoints, + context.Ct).ConfigureAwait(false); + + var results = response.Validate(response.Results, r => r.StatusCode, + response.DiagnosticInfos, continuationPoints); + if (results.ErrorInfo != null) + { + return HandleError(context, results.ErrorInfo); + } + + var refs = MatchReferences(frame, context, results[0].Result.References, + results[0].ErrorInfo); + + var continuation = results[0].Result.ContinuationPoint ?? Array.Empty(); + if (continuation.Length > 0) + { + Push(session => BrowseNextAsync(session, continuation, frame)); + } + else + { + Push(context => BrowseAsync(context)); + } + return refs; + } + + /// + /// Collect references + /// + /// + /// + /// + /// + /// + private IEnumerable MatchReferences(BrowseFrame frame, ServiceCallContext context, + ReferenceDescriptionCollection refs, ServiceResultModel? errorInfo) + { + if (errorInfo != null) + { + return HandleError(context, errorInfo); + } + + var matching = refs + .Where(reference => reference.NodeClass == _matchClass + && (reference.NodeId?.ServerIndex ?? 1u) == 0) + .Where(reference => _typeDefinitionId == null || + reference.TypeDefinition == _typeDefinitionId || (_includeTypeDefinitionSubtypes + && context.Session.TypeTree.IsTypeOf(reference.TypeDefinition, _typeDefinitionId))) + .Select(reference => new BrowseFrame((NodeId)reference.NodeId, + reference.BrowseName, reference.DisplayName?.Text, frame)) + .ToList(); + + if (_stopWhenFound && matching.Count != 0) + { + // Only add what we did not match to browser deeper + var stop = matching.Select(r => r.NodeId).ToHashSet(); + foreach (var reference in refs) + { + if (!stop.Contains((NodeId)reference.NodeId)) + { + Push(reference.NodeId, reference.BrowseName, + reference.DisplayName?.Text, frame); + } + } + } + else + { + // Browse deeper in if possible + foreach (var reference in refs) + { + Push(reference.NodeId, reference.BrowseName, + reference.DisplayName?.Text, frame); + } + } + + if (matching.Count == 0) + { + return Enumerable.Empty(); + } + + // Pass matching on + return HandleMatching(context, matching); + } + + /// + /// Initialize + /// + private void Start() + { + // Initialize + _visited.Clear(); + _browseStack.Push(new BrowseFrame(_root, null, null)); + Push(context => BrowseAsync(context)); + } + + /// + /// Helper to push nodes onto the browse stack + /// + /// + /// + /// + /// + private void Push(ExpandedNodeId nodeId, QualifiedName? browseName, + string? displayName, BrowseFrame? parent) + { + if ((nodeId?.ServerIndex ?? 1u) != 0) + { + return; + } + var local = (NodeId)nodeId; + if (!NodeId.IsNull(local) && !_visited.Contains(local)) + { + var frame = new BrowseFrame(local, browseName, displayName, parent); + if (_maxDepth.HasValue && frame.Depth >= _maxDepth.Value) + { + return; + } + _browseStack.Push(frame); + } + } + + /// + /// Helper to pop nodes from the browse stack + /// + /// + private BrowseFrame? Pop() + { + while (_browseStack.TryPop(out var frame)) + { + if (!NodeId.IsNull(frame.NodeId) && !_visited.Contains(frame.NodeId)) + { + return frame; + } + } + return null; + } + + /// + /// Tracks a reference to a node + /// + /// + /// + /// + /// + protected record class BrowseFrame(NodeId NodeId, QualifiedName? BrowseName, + string? DisplayName, BrowseFrame? Parent = null) + { + /// + /// Current depth of this frame + /// + public uint Depth + { + get + { + var depth = 0u; + for (var parent = Parent; parent != null; parent = parent.Parent) + { + depth++; + } + return depth; + } + } + + /// + /// Browse path to the node + /// + public string BrowsePath + { + get + { + var path = BrowseName; + for (var parent = Parent; parent?.BrowseName != null; parent = parent.Parent) + { + path = $"{parent.BrowseName}/{path}"; + } + return "/" + (path ?? string.Empty); + } + } + } + + /// + /// Restart browsing with different configuration + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + private void Initialize(uint? maxDepth, NodeId? root, Opc.Ua.NodeClass nodeClass, + NodeId? typeDefinitionId, bool includeTypeDefinitionSubtypes, + NodeId? referenceTypeId, bool includeReferenceTypeSubtypes, + bool stopWhenFound, Opc.Ua.NodeClass? matchClass) + { + _stopWhenFound = stopWhenFound; + _nodeClass = nodeClass; + _matchClass = matchClass ?? nodeClass; + _maxDepth = maxDepth; + _root = root ?? ObjectIds.ObjectsFolder; + + _typeDefinitionId = + typeDefinitionId; + _includeTypeDefinitionSubtypes = + includeTypeDefinitionSubtypes; + _referenceTypeId = + referenceTypeId ?? ReferenceTypeIds.HierarchicalReferences; + _includeReferenceTypeSubtypes = + includeReferenceTypeSubtypes; + } + + private bool _stopWhenFound; + private Opc.Ua.NodeClass _nodeClass; + private Opc.Ua.NodeClass _matchClass; + private uint? _maxDepth; + private NodeId _root; + private NodeId _referenceTypeId; + private bool _includeReferenceTypeSubtypes; + private NodeId? _typeDefinitionId; + private bool _includeTypeDefinitionSubtypes; + private readonly Stack _browseStack = new(); + private readonly HashSet _visited = new(); + private readonly ActivitySource _activitySource = Diagnostics.NewActivitySource(); + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/ConfigurationServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/ConfigurationServices.cs new file mode 100644 index 0000000000..c0e2581573 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/ConfigurationServices.cs @@ -0,0 +1,761 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Services +{ + using Azure.IIoT.OpcUa.Publisher; + using Azure.IIoT.OpcUa.Publisher.Config.Models; + using Azure.IIoT.OpcUa.Publisher.Models; + using Azure.IIoT.OpcUa.Publisher.Stack; + using Azure.IIoT.OpcUa.Publisher.Stack.Extensions; + using Azure.IIoT.OpcUa.Publisher.Stack.Models; + using Furly.Exceptions; + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Options; + using Opc.Ua; + using Opc.Ua.Extensions; + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Configuration services uses the address space and services of a connected server to + /// configure the publisher. The configuration services allow interactive expansion of + /// published nodes. + /// + public sealed class ConfigurationServices : IConfigurationServices + { + /// + /// Create configuration services + /// + /// + /// + /// + /// + /// + public ConfigurationServices(IPublishedNodesServices configuration, + IOpcUaClientManager client, IOptions options, + ILogger logger, TimeProvider? timeProvider = null) + { + _configuration = configuration; + _client = client; + _options = options; + _logger = logger; + _timeProvider = timeProvider; + } + + /// + public IAsyncEnumerable> ExpandAsync( + PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(entry.OpcNodes); + ValidateNodes(entry.OpcNodes); + + var browser = new ConfigBrowser(entry, request, _options, null, + _logger, _timeProvider); + return _client.ExecuteAsync(entry.ToConnectionModel(), browser, request.Header, ct); + } + + /// + public IAsyncEnumerable> CreateOrUpdateAsync( + PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, + CancellationToken ct) + { + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(entry); + ArgumentNullException.ThrowIfNull(entry.OpcNodes); + ValidateNodes(entry.OpcNodes); + + var browser = new ConfigBrowser(entry, request, _options, _configuration, + _logger, _timeProvider); + return _client.ExecuteAsync(entry.ToConnectionModel(), browser, request.Header, ct); + } + + /// + /// Validate nodes are valid + /// + /// + /// + /// + private static IList ValidateNodes(IList opcNodes) + { + var set = new HashSet(); + foreach (var node in opcNodes) + { + if (!node.TryGetId(out var id)) + { + throw new BadRequestException("Node must contain a node ID"); + } + node.DataSetFieldId ??= id; + set.Add(node.DataSetFieldId); + if (node.OpcPublishingInterval != null || + node.OpcPublishingIntervalTimespan != null) + { + throw new BadRequestException( + "Publishing interval not allowed on node level. " + + "Must be set at writer level."); + } + } + if (set.Count != opcNodes.Count) + { + throw new BadRequestException("Field ids must be present and unique."); + } + return opcNodes; + } + + /// + /// Configuration browser + /// + internal sealed class ConfigBrowser : AsyncEnumerableBrowser> + { + /// + public ConfigBrowser(PublishedNodesEntryModel entry, PublishedNodeExpansionModel request, + IOptions options, IPublishedNodesServices? configuration, ILogger logger, + TimeProvider? timeProvider = null) : base(request.Header, options, timeProvider) + { + _entry = entry; + _request = request; + _configuration = configuration; + _logger = logger; + } + + /// + public override void Reset() + { + base.Reset(); + + _nodeIndex = -1; + _expanded.Clear(); + + Push(context => BeginAsync(context)); + } + + /// + protected override void OnReset() + { + // We handle our own restarts + } + + /// + protected override IEnumerable> HandleError( + ServiceCallContext context, ServiceResultModel errorInfo) + { + CurrentNode.AddErrorInfo(errorInfo); + return Enumerable.Empty>(); + } + + /// + protected override IEnumerable> HandleMatching( + ServiceCallContext context, IReadOnlyList matching) + { + if (_currentObject != null) + { + // collect matching variables under the current object instance + _currentObject.AddVariables(matching); + } + else + { + // collect matching object instances + CurrentNode.AddObjectsOrVariables(matching); + } + return Enumerable.Empty>(); + } + + /// + protected override IEnumerable> HandleCompletion( + ServiceCallContext context) + { + Push(context => CompleteAsync(context)); + return Enumerable.Empty>(); + } + + /// + /// Complete the browse operation and resolve objects + /// + /// + /// + private async ValueTask>> CompleteAsync( + ServiceCallContext context) + { + var entries = new List>(); + var currentObject = _currentObject; + if (currentObject != null) + { + // Process current object + await ProcessAsync(currentObject, context, entries).ConfigureAwait(false); + + if (TryMoveToNextObject()) + { + // Kicked off the next expansion + return entries; + } + Debug.Assert(_currentObject == null); + } + else if (CurrentNode.Variables.ContainsVariables) + { + // Completing a browse for variables + await ProcessAsync(CurrentNode.Variables, context, entries).ConfigureAwait(false); + } + else if (!CurrentNode.ContainsObjects) + { + // Completing a browse for objects + if (!CurrentNode.HasErrors) + { + CurrentNode.AddErrorInfo(StatusCodes.BadNotFound, "No objects resolved."); + } + } + if (!TryMoveToNextNode()) + { + // Complete + entries.AddRange(await EndAsync(context).ConfigureAwait(false)); + } + return entries; + + async Task ProcessAsync(ObjectToExpand currentObject, ServiceCallContext context, + List> entries) + { + if (currentObject.ContainsVariables && + !_request.CreateSingleWriter && !currentObject.OriginalNode.HasErrors) + { + // Create a new writer entry for the object + var entry = new ServiceResponse + { + Result = _entry with + { + DataSetName = currentObject.CreateDataSetName( + context.Session.MessageContext), + DataSetWriterId = currentObject.CreateWriterId( + context.Session.MessageContext), + OpcNodes = currentObject + .GetOpcNodeModels( + currentObject.OriginalNode.NodeFromConfiguration, + context.Session.MessageContext) + .ToList() + } + }; + + await SaveEntryAsync(entry, context.Ct).ConfigureAwait(false); + + currentObject.EntriesAlreadyReturned = true; + if (!_request.DiscardErrors || entry.ErrorInfo == null) + { + // Add good entry to return _now_ + entries.Add(entry); + } + } + } + } + + /// + /// Start by resolving nodes and starting the browse operation + /// + /// + /// + private async ValueTask>> BeginAsync( + ServiceCallContext context) + { + if (_entry.OpcNodes?.Count > 0) + { + // TODO: Could be done in one request for better efficiency + foreach (var node in _entry.OpcNodes) + { + var nodeId = await context.Session.ResolveNodeIdAsync(_request.Header, + node.Id, node.BrowsePath, nameof(node.BrowsePath), TimeProvider, + context.Ct).ConfigureAwait(false); + _expanded.Add(new NodeToExpand(node, nodeId)); + } + + // Resolve node classes + var results = await context.Session.ReadAttributeAsync( + _request.Header.ToRequestHeader(TimeProvider), + _expanded.Select(r => r.NodeId ?? NodeId.Null), + (uint)NodeAttribute.NodeClass, context.Ct).ConfigureAwait(false); + foreach (var result in results.Zip(_expanded)) + { + result.Second.AddErrorInfo(result.First.Item2); + result.Second.NodeClass = (uint)result.First.Item1; + } + if (!TryMoveToNextNode()) + { + // Complete + return await EndAsync(context).ConfigureAwait(false); + } + } + return Enumerable.Empty>(); + } + + /// + /// Return remaining entries + /// + /// + /// + /// + private async ValueTask>> EndAsync( + ServiceCallContext context) + { + var results = new List>(); + var ids = new HashSet(); + var goodNodes = _expanded + .Where(e => !e.HasErrors) + .SelectMany(r => r.GetAllOpcNodeModels(context.Session.MessageContext, ids)) + .ToList(); + if (goodNodes.Count > 0) + { + var entry = new ServiceResponse + { + Result = _entry with { OpcNodes = goodNodes } + }; + await SaveEntryAsync(entry, context.Ct).ConfigureAwait(false); + if (!_request.DiscardErrors || entry.ErrorInfo == null) + { + // Add good entry + results.Add(entry); + } + } + if (!_request.DiscardErrors) + { + var badNodes = _expanded + .Where(e => e.HasErrors) + .SelectMany(e => e.ErrorInfos + .Select(error => (error, e + .GetAllOpcNodeModels(context.Session.MessageContext, ids, true) + .ToList()))) + .GroupBy(e => e.error) + .SelectMany(r => r.Select(r => r)) + .ToList(); + foreach (var entry in badNodes) + { + // Return bad entries + results.Add(new ServiceResponse + { + ErrorInfo = entry.error, + Result = _entry with { OpcNodes = entry.Item2 } + }); + } + } + _nodeIndex = -1; + _expanded.Clear(); + return results; + } + + /// + /// Try move to next node + /// + /// + private bool TryMoveToNextNode() + { + Debug.Assert(_currentObject == null); + _nodeIndex++; + for (; _nodeIndex < _expanded.Count; _nodeIndex++) + { + switch (CurrentNode.NodeClass) + { + case (uint)Opc.Ua.NodeClass.Object: + // Resolve all objects under this object + Debug.Assert(!NodeId.IsNull(CurrentNode.NodeId)); + if (!_request.ExcludeRootIfInstanceNode) + { + // Add root + CurrentNode.AddObjectsOrVariables( + new BrowseFrame(CurrentNode.NodeId!, null, null).YieldReturn()); + + if (_request.MaxDepth == 0) + { + // We have the object - browse it now + return TryMoveToNextObject(); + } + } + var depth = _request.MaxDepth == 0 ? 1 : _request.MaxDepth; + var refTypeId = _request.StopAtFirstFoundInstance ? + ReferenceTypeIds.Organizes : ReferenceTypeIds.HierarchicalReferences; + Restart(CurrentNode.NodeId, maxDepth: depth, referenceTypeId: refTypeId); + return true; + case (uint)Opc.Ua.NodeClass.VariableType: + case (uint)Opc.Ua.NodeClass.ObjectType: + // Resolve all objects of this type + Debug.Assert(!NodeId.IsNull(CurrentNode.NodeId)); + var instanceClass = + CurrentNode.NodeClass == (uint)Opc.Ua.NodeClass.ObjectType ? + Opc.Ua.NodeClass.Object : Opc.Ua.NodeClass.Variable; + + // If stop at first found we only need to use organizes references + var referenceTypeId = + _request.StopAtFirstFoundInstance && + instanceClass == Opc.Ua.NodeClass.Object ? + ReferenceTypeIds.Organizes : ReferenceTypeIds.HierarchicalReferences; + + Restart(ObjectIds.ObjectsFolder, maxDepth: _request.MaxDepth, + typeDefinitionId: CurrentNode.NodeId, + stopWhenFound: _request.StopAtFirstFoundInstance, + referenceTypeId: referenceTypeId, matchClass: instanceClass); + return true; + case (uint)Opc.Ua.NodeClass.Variable: + if (!_request.ExcludeRootIfInstanceNode) + { + // Add root + CurrentNode.AddObjectsOrVariables( + new BrowseFrame(CurrentNode.NodeId!, null, null).YieldReturn()); + + if (_request.MaxLevelsToExpand == 0) + { + // Done - already a variable - stays in the original entry + break; + } + } + // Now we expand the variable here + Restart(CurrentNode.NodeId, + _request.MaxLevelsToExpand == 0 ? 1 : _request.MaxLevelsToExpand, + referenceTypeId: ReferenceTypeIds.Aggregates, + nodeClass: Opc.Ua.NodeClass.Variable); + return true; + case (uint)Opc.Ua.NodeClass.Unspecified: + // There should already be an error here + if (CurrentNode.HasErrors) + { + break; + } + goto default; + default: + CurrentNode.AddErrorInfo(StatusCodes.BadNotSupported, + $"Node class {CurrentNode.NodeClass} not supported."); + break; + } + } + return TryMoveToNextObject(); + } + + /// + /// Find next object to expand + /// + /// + private bool TryMoveToNextObject() + { + foreach (var node in _expanded) + { + if (node.TryGetNextObject(out _currentObject)) + { + Debug.Assert(_currentObject != null); + Restart(_currentObject.ObjectFromBrowse.NodeId, + _request.MaxLevelsToExpand == 0 ? null : _request.MaxLevelsToExpand, + referenceTypeId: ReferenceTypeIds.Aggregates, + nodeClass: Opc.Ua.NodeClass.Variable); + return true; + } + } + return false; + } + + /// + /// Save entry if update is enabled + /// + /// + /// + /// + private async ValueTask SaveEntryAsync(ServiceResponse entry, + CancellationToken ct) + { + Debug.Assert(entry.Result != null); + Debug.Assert(entry.Result.OpcNodes != null); + Debug.Assert(entry.ErrorInfo == null); + try + { + ValidateNodes(entry.Result.OpcNodes); + if (_configuration != null) + { + await _configuration.CreateOrUpdateDataSetWriterEntryAsync(entry.Result, + ct).ConfigureAwait(false); + } + } + catch (Exception ex) + { + entry.ErrorInfo = ex.ToServiceResultModel(); + } + } + + /// + /// Get current node to expand + /// + private NodeToExpand CurrentNode + { + get + { + Debug.Assert(_nodeIndex < _expanded.Count); + return _expanded[_nodeIndex]; + } + } + + /// + /// Node that should be expanded + /// + private record class NodeToExpand + { + public IEnumerable ErrorInfos => _errorInfos; + + public bool HasErrors => _errorInfos.Count > 0; + + public bool ContainsObjects => _objects.Count > 0; + + public ObjectToExpand Variables { get; } + + /// + /// Original node from configuration + /// + public OpcNodeModel NodeFromConfiguration { get; } + + /// + /// Node id that should be expanded + /// + public NodeId? NodeId { get; } + + /// + /// Node class of the node + /// + public uint NodeClass { get; internal set; } + + /// + /// Create node to expand + /// + /// + /// + public NodeToExpand(OpcNodeModel nodeFromConfiguration, NodeId? nodeId) + { + NodeFromConfiguration = nodeFromConfiguration; + NodeId = nodeId; + + // Hold variables resolved from a variable or variable type + Variables = new ObjectToExpand( + new BrowseFrame(nodeId ?? NodeId.Null, "Variables", "Variables"), this); + } + + /// + /// Opc node model configurations over all objects + /// + /// + /// + /// + /// + public IEnumerable GetAllOpcNodeModels(IServiceMessageContext context, + HashSet? ids = null, bool error = false) + { + switch (NodeClass) + { + case (uint)Opc.Ua.NodeClass.VariableType: + case (uint)Opc.Ua.NodeClass.Variable: + if (Variables.EntriesAlreadyReturned) + { + break; + } + var variables = Variables.GetOpcNodeModels(NodeFromConfiguration, + context, ids, true); + if ((!error && NodeClass == (uint)Opc.Ua.NodeClass.VariableType) || + ids?.Contains(NodeFromConfiguration.DataSetFieldId) == true) + { + // Only variables, not the root variable + return variables; + } + return variables.Prepend(NodeFromConfiguration); + case (uint)Opc.Ua.NodeClass.Object: + case (uint)Opc.Ua.NodeClass.ObjectType: + var objects = _objects + .Where(o => !o.EntriesAlreadyReturned) + .SelectMany(o => o.GetOpcNodeModels( + NodeFromConfiguration, context, ids, true)); + if (!error) + { + return objects; + } + return objects.Prepend(NodeFromConfiguration); + } + return error ? new[] { NodeFromConfiguration } : Array.Empty(); + } + + /// + /// Add objects or variables depending on the node class that is expanded + /// + /// + public void AddObjectsOrVariables(IEnumerable frames) + { + if (NodeClass == (uint)Opc.Ua.NodeClass.VariableType || + NodeClass == (uint)Opc.Ua.NodeClass.Variable) + { + Variables.AddVariables(frames + .Where(f => !NodeId.IsNull(f.NodeId) && _knownIds.Add(f.NodeId))); + } + else + { + _objects.AddRange(frames + .Where(f => !NodeId.IsNull(f.NodeId) && _knownIds.Add(f.NodeId)) + .Select(f => new ObjectToExpand(f, this))); + } + } + + /// + /// Add error info + /// + /// + /// + public void AddErrorInfo(uint statusCode, string message) + { + _errorInfos.Add(new ServiceResultModel + { + ErrorMessage = message, + StatusCode = statusCode + }); + } + + /// + /// Add error info + /// + /// + public void AddErrorInfo(ServiceResultModel? errorInfo) + { + if (errorInfo != null) + { + _errorInfos.Add(errorInfo); + } + } + + /// + /// Move to next object + /// + /// + /// + public bool TryGetNextObject(out ObjectToExpand? obj) + { + if (_objectIndex < _objects.Count) + { + obj = _objects[_objectIndex]; + _objectIndex++; + return true; + } + obj = null; + return false; + } + + private readonly List _errorInfos = new(); + private readonly List _objects = new(); + private readonly HashSet _knownIds = new(); + private int _objectIndex; + } + + /// + /// The object to expand + /// + /// + /// + private record class ObjectToExpand(BrowseFrame ObjectFromBrowse, NodeToExpand OriginalNode) + { + public bool EntriesAlreadyReturned { get; internal set; } + + public bool ContainsVariables => _variables.Count > 0; + + /// + /// Add variables + /// + /// + public void AddVariables(IEnumerable frames) + { + foreach (var frame in frames) + { + var added = _variables.Add(frame); +#if DEBUG + Debug.Assert(added, $"Variable {frame} already exists"); +#endif + } + } + + /// + /// Get node models + /// + /// + /// + /// + /// + /// + public IEnumerable GetOpcNodeModels(OpcNodeModel template, + IServiceMessageContext context, HashSet? ids = null, + bool createLongIds = false) + { + ids ??= new HashSet(); + if (EntriesAlreadyReturned) + { + return Enumerable.Empty(); + } + return _variables.Select(frame => template with + { + Id = frame.NodeId.AsString(context, NamespaceFormat.Expanded), + AttributeId = null, // Defaults to variable + DataSetFieldId = CreateUniqueId(frame), + }); + + string CreateUniqueId(BrowseFrame frame) + { + var id = template.DataSetFieldId ?? string.Empty; + id = createLongIds ? + $"{id}{ObjectFromBrowse.BrowsePath}{frame.BrowsePath}" : + $"{id}{frame.BrowsePath}"; + var uniqueId = id; + for (var index = 1; !ids.Add(uniqueId); index++) + { + uniqueId = $"{id}_{index}"; + } + return uniqueId; + } + } + + /// + /// Create writer id for the object + /// + /// + /// + public string CreateWriterId(IServiceMessageContext context) + { + var sb = new StringBuilder(); + if (OriginalNode.NodeFromConfiguration.DataSetFieldId != null) + { + sb = sb.Append(OriginalNode.NodeFromConfiguration.DataSetFieldId); + } + return sb.Append(ObjectFromBrowse.BrowsePath).ToString(); + } + + /// + /// Create data set name for the object + /// + /// + /// + public string CreateDataSetName(IServiceMessageContext context) + { + var result = ObjectFromBrowse.NodeId.AsString(context, + NamespaceFormat.Expanded)!; + Debug.Assert(result != null); + return result; + } + + private readonly HashSet _variables = new(); + } + + private int _nodeIndex = -1; + private ObjectToExpand? _currentObject; + private readonly List _expanded = new(); + private readonly PublishedNodesEntryModel _entry; + private readonly PublishedNodeExpansionModel _request; + private readonly IPublishedNodesServices? _configuration; + private readonly ILogger _logger; + } + + private readonly IPublishedNodesServices _configuration; + private readonly IOptions _options; + private readonly IOpcUaClientManager _client; + private readonly ILogger _logger; + private readonly TimeProvider? _timeProvider; + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/Extensions.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/Extensions.cs new file mode 100644 index 0000000000..22855abd93 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/Extensions.cs @@ -0,0 +1,107 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Services +{ + using Azure.IIoT.OpcUa.Publisher.Models; + using Azure.IIoT.OpcUa.Publisher.Parser; + using Azure.IIoT.OpcUa.Publisher.Stack; + using Azure.IIoT.OpcUa.Publisher.Stack.Models; + using Furly.Exceptions; + using Opc.Ua; + using Opc.Ua.Extensions; + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Service Extensions + /// + internal static class Extensions + { + /// + /// Resolve node id + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task ResolveNodeIdAsync(this IOpcUaSession session, + RequestHeaderModel? header, string? rootId, IReadOnlyList? browsePath, + string paramName, TimeProvider timeProvider, CancellationToken ct = default) + { + var resolvedNodeId = rootId.ToNodeId(session.MessageContext); + if (browsePath?.Count > 0) + { + resolvedNodeId = await session.ResolveBrowsePathToNodeAsync(header, + resolvedNodeId, browsePath.ToArray(), paramName, + timeProvider, ct).ConfigureAwait(false); + } + return resolvedNodeId; + } + + /// + /// Resolve provided path to node. + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task ResolveBrowsePathToNodeAsync( + this IOpcUaSession session, RequestHeaderModel? header, NodeId rootId, + string[] paths, string paramName, TimeProvider timeProvider, + CancellationToken ct = default) + { + if (paths == null || paths.Length == 0) + { + return rootId; + } + if (NodeId.IsNull(rootId)) + { + rootId = ObjectIds.RootFolder; + } + var browsepaths = new BrowsePathCollection + { + new BrowsePath + { + StartingNode = rootId, + RelativePath = paths.ToRelativePath(session.MessageContext) + } + }; + var response = await session.Services.TranslateBrowsePathsToNodeIdsAsync( + header.ToRequestHeader(timeProvider), browsepaths, + ct).ConfigureAwait(false); + Debug.Assert(response != null); + var results = response.Validate(response.Results, r => r.StatusCode, + response.DiagnosticInfos, browsepaths); + var count = results[0].Result.Targets?.Count ?? 0; + if (count == 0) + { + throw new ResourceNotFoundException( + $"{paramName} did not resolve to any node."); + } + if (count != 1) + { + throw new ResourceConflictException( + $"{paramName} resolved to {count} nodes."); + } + return results[0].Result.Targets[0].TargetId + .ToNodeId(session.MessageContext.NamespaceUris); + } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/FileSystemServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/FileSystemServices.cs new file mode 100644 index 0000000000..960801f061 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/FileSystemServices.cs @@ -0,0 +1,894 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Services +{ + using Azure.IIoT.OpcUa.Publisher.Models; + using Azure.IIoT.OpcUa.Publisher.Parser; + using Azure.IIoT.OpcUa.Publisher.Stack; + using Azure.IIoT.OpcUa.Publisher.Stack.Extensions; + using Azure.IIoT.OpcUa.Publisher.Stack.Models; + using Microsoft.Extensions.Options; + using Opc.Ua; + using Opc.Ua.Extensions; + using System; + using System.Buffers; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + /// + /// This class provides foundational file transfer services. + /// + /// + public sealed class FileSystemServices : IFileSystemServices, IDisposable + { + /// + /// Create node service + /// + /// + /// + /// + public FileSystemServices(IOpcUaClientManager client, + IOptions options, TimeProvider? timeProvider = null) + { + _client = client; + _options = options; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + /// + public void Dispose() + { + _activitySource.Dispose(); + } + + /// + public IAsyncEnumerable> GetFileSystemsAsync( + T endpoint, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("GetFileSystems"); + var header = new RequestHeaderModel(); + + var browser = new FileSystemBrowser(header, _options, _timeProvider); + return _client.ExecuteAsync(endpoint, browser, header, ct); + } + + /// + public async Task>> GetDirectoriesAsync( + T endpoint, FileSystemObjectModel fileSystemOrDirectory, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("GetDirectories"); + var header = new RequestHeaderModel(); + + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return new ServiceResponse> + { + ErrorInfo = argInfo + }; + } + var (references, errorInfo) = await context.Session.FindAsync( + header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(), + ReferenceTypeIds.HasComponent, ct: context.Ct).ConfigureAwait(false); + if (errorInfo == null && references.Count > 0 && + references.All(r => r.ErrorInfo != null)) + { + errorInfo = references[0].ErrorInfo; + } + return new ServiceResponse> + { + ErrorInfo = errorInfo, + Result = references + .Where(r => r.TypeDefinition == Opc.Ua.ObjectTypes.FileDirectoryType && + r.ErrorInfo == null) + .Select(f => new FileSystemObjectModel + { + NodeId = header.AsString(f.Node, context.Session.MessageContext, _options), + Name = f.DisplayName + }) + }; + }, header, ct).ConfigureAwait(false); + } + + /// + public async Task>> GetFilesAsync( + T endpoint, FileSystemObjectModel fileSystemOrDirectory, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("GetFiles"); + var header = new RequestHeaderModel(); + + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return new ServiceResponse> + { + ErrorInfo = argInfo + }; + } + + var (references, errorInfo) = await context.Session.FindAsync( + header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(), + ReferenceTypeIds.HasComponent, ct: context.Ct).ConfigureAwait(false); + if (errorInfo == null && references.Count > 0 && + references.All(r => r.ErrorInfo != null)) + { + errorInfo = references[0].ErrorInfo; + } + return new ServiceResponse> + { + ErrorInfo = errorInfo, + Result = references + .Where(r => r.TypeDefinition == Opc.Ua.ObjectTypes.FileType && + r.ErrorInfo == null) + .Select(f => new FileSystemObjectModel + { + NodeId = header.AsString(f.Node, context.Session.MessageContext, _options), + Name = f.DisplayName + }) + }; + }, header, ct).ConfigureAwait(false); + } + + /// + public async Task> OpenReadAsync(T endpoint, + FileSystemObjectModel file, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("OpenRead"); + var header = new RequestHeaderModel(); + var (stream, errorInfo) = await FileTransferStream.OpenAsync(this, + endpoint, header, file, null, ct).ConfigureAwait(false); + return new ServiceResponse + { + ErrorInfo = errorInfo, + Result = stream + }; + } + + /// + public async Task> OpenWriteAsync(T endpoint, + FileSystemObjectModel file, FileWriteMode mode, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("OpenWrite"); + var header = new RequestHeaderModel(); + var (stream, errorInfo) = await FileTransferStream.OpenAsync(this, + endpoint, header, file, mode, ct).ConfigureAwait(false); + return new ServiceResponse + { + ErrorInfo = errorInfo, + Result = stream + }; + } + + /// + public async Task> CreateDirectoryAsync(T endpoint, + FileSystemObjectModel fileSystemOrDirectory, string name, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("CreateDirectory"); + var header = new RequestHeaderModel(); + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return new ServiceResponse { ErrorInfo = argInfo }; + } + var requests = new CallMethodRequestCollection + { + new CallMethodRequest + { + ObjectId = nodeId, + MethodId = Opc.Ua.MethodIds.FileDirectoryType_CreateDirectory, + InputArguments = new [] { new Variant(name) } + } + }; + // Call method + var response = await context.Session.Services.CallAsync(header + .ToRequestHeader(_timeProvider), requests, context.Ct).ConfigureAwait(false); + var results = response.Validate(response.Results, r => r.StatusCode, + response.DiagnosticInfos, requests); + if (results.ErrorInfo != null) + { + return new ServiceResponse { ErrorInfo = results.ErrorInfo }; + } + if (results[0].ErrorInfo != null || + results[0].Result?.OutputArguments == null || + results[0].Result.OutputArguments.Count == 0 || + results[0].Result.OutputArguments[0].Value is not NodeId result) + { + return new ServiceResponse + { + ErrorInfo = results[0].ErrorInfo ?? + new ServiceResultModel { ErrorMessage = "no node id returned" } + }; + } + return new ServiceResponse + { + Result = new FileSystemObjectModel + { + NodeId = header.AsString(result, context.Session.MessageContext, _options), + Name = name + } + }; + }, header, ct).ConfigureAwait(false); + } + + /// + public async Task> CreateFileAsync(T endpoint, + FileSystemObjectModel fileSystemOrDirectory, string name, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("CreateFile"); + var header = new RequestHeaderModel(); + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return new ServiceResponse { ErrorInfo = argInfo }; + } + + var requests = new CallMethodRequestCollection + { + new CallMethodRequest + { + ObjectId = nodeId, + MethodId = Opc.Ua.MethodIds.FileDirectoryType_CreateFile, + InputArguments = new [] { new Variant(name), new Variant(false) } + } + }; + // Call method + var response = await context.Session.Services.CallAsync(header + .ToRequestHeader(_timeProvider), requests, context.Ct).ConfigureAwait(false); + + var results = response.Validate(response.Results, r => r.StatusCode, + response.DiagnosticInfos, requests); + if (results.ErrorInfo != null) + { + return new ServiceResponse { ErrorInfo = results.ErrorInfo }; + } + if (results[0].ErrorInfo != null || + results[0].Result?.OutputArguments == null || + results[0].Result.OutputArguments.Count == 0 || + results[0].Result.OutputArguments[0].Value is not NodeId result) + { + return new ServiceResponse + { + ErrorInfo = results[0].ErrorInfo ?? + new ServiceResultModel { ErrorMessage = "no node id returned" } + }; + } + return new ServiceResponse + { + Result = new FileSystemObjectModel + { + NodeId = header.AsString(result, context.Session.MessageContext, _options), + Name = name + } + }; + }, header, ct).ConfigureAwait(false); + } + + /// + public async Task> GetParentAsync(T endpoint, + FileSystemObjectModel fileOrDirectoryObject, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("GetParent"); + var header = new RequestHeaderModel(); + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, fileOrDirectoryObject, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return new ServiceResponse { ErrorInfo = argInfo }; + } + + // Find parent + var (parents, argInfo2) = await context.Session.FindAsync( + header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(), + ReferenceTypeIds.HasComponent, isInverse: true, + maxResults: 1, ct: context.Ct).ConfigureAwait(false); + if (argInfo2 != null) + { + return new ServiceResponse { ErrorInfo = argInfo2 }; + } + var result = parents.Count > 0 ? parents[0] : default; + nodeId = result.Node; + if (NodeId.IsNull(nodeId) || + result.TypeDefinition != Opc.Ua.ObjectTypeIds.FileDirectoryType) + { + return new ServiceResponse + { + ErrorInfo = new ServiceResultModel + { + StatusCode = StatusCodes.BadNodeIdInvalid, + ErrorMessage = "Could not find a file directory object parent." + } + }; + } + return new ServiceResponse + { + Result = new FileSystemObjectModel + { + NodeId = header.AsString(nodeId, context.Session.MessageContext, _options), + Name = result.DisplayName + } + }; + }, header, ct).ConfigureAwait(false); + } + + /// + public async Task DeleteFileSystemObjectAsync(T endpoint, + FileSystemObjectModel fileOrDirectoryObject, FileSystemObjectModel? parentFileSystemOrDirectory, + CancellationToken ct) + { + using var trace = _activitySource.StartActivity("DeleteFileSystemObject"); + var header = new RequestHeaderModel(); + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, fileOrDirectoryObject, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return argInfo; + } + + var targetId = nodeId; + if (parentFileSystemOrDirectory != null) + { + (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, parentFileSystemOrDirectory, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return argInfo; + } + } + else + { + // Find parent + var (parents, argInfo2) = await context.Session.FindAsync( + header.ToRequestHeader(_timeProvider), targetId.YieldReturn(), + ReferenceTypeIds.HasComponent, isInverse: true, + maxResults: 1, ct: context.Ct).ConfigureAwait(false); + if (argInfo2 != null) + { + return argInfo2; + } + var result = parents.Count > 0 ? parents[0] : default; + nodeId = result.Node; + if (NodeId.IsNull(nodeId) || + result.TypeDefinition != Opc.Ua.ObjectTypeIds.FileDirectoryType) + { + return new ServiceResultModel + { + StatusCode = StatusCodes.BadNodeIdInvalid, + ErrorMessage = "Could not find a file directory object parent." + }; + } + } + var requests = new CallMethodRequestCollection + { + new CallMethodRequest + { + ObjectId = nodeId, + MethodId = Opc.Ua.MethodIds.FileDirectoryType_DeleteFileSystemObject, + InputArguments = new [] { new Variant(targetId) } + } + }; + // Call method + var response = await context.Session.Services.CallAsync(header + .ToRequestHeader(_timeProvider), requests, context.Ct).ConfigureAwait(false); + + var results = response.Validate(response.Results, r => r.StatusCode, + response.DiagnosticInfos, requests); + return results.ErrorInfo ?? results[0].ErrorInfo ?? new ServiceResultModel(); + }, header, ct).ConfigureAwait(false); + } + + /// + public async Task> GetFileInfoAsync(T endpoint, + FileSystemObjectModel file, CancellationToken ct) + { + using var trace = _activitySource.StartActivity("GetFileInfo"); + var header = new RequestHeaderModel(); + return await _client.ExecuteAsync(endpoint, async context => + { + var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, + header, file, context.Ct).ConfigureAwait(false); + if (argInfo != null) + { + return new ServiceResponse { ErrorInfo = argInfo }; + } + var (fileInfo, errorInfo) = await context.Session.GetFileInfoAsync( + header.ToRequestHeader(_timeProvider), nodeId, context.Ct).ConfigureAwait(false); + return new ServiceResponse + { + ErrorInfo = errorInfo, + Result = fileInfo + }; + }, header, ct).ConfigureAwait(false); + } + + /// + /// Get the node id for a file system object + /// + /// + /// + /// + /// + /// + /// + private async Task<(NodeId, ServiceResultModel?)> GetFileSystemNodeIdAsync(IOpcUaSession session, + RequestHeaderModel header, FileSystemObjectModel fileSystemObject, + CancellationToken ct) + { + var nodeId = fileSystemObject.NodeId.ToNodeId(session.MessageContext); + if (fileSystemObject.BrowsePath?.Count > 0) + { + nodeId ??= ObjectIds.RootFolder; + try + { + nodeId = await session.ResolveBrowsePathToNodeAsync(header, + nodeId, fileSystemObject.BrowsePath.Select(b => "" + b).ToArray(), + nameof(fileSystemObject.BrowsePath), _timeProvider, ct).ConfigureAwait(false); + } + catch (Exception ex) + { + return (NodeId.Null, ex.ToServiceResultModel()); + } + } + if (NodeId.IsNull(nodeId)) + { + return (NodeId.Null, new ServiceResultModel + { + StatusCode = StatusCodes.BadNodeIdInvalid, + ErrorMessage = "Invalid node id and browse path in file system object" + }); + } + return (nodeId, null); + } + + /// + /// File system object browser browses from root all objects of file directory type. + /// The browse operation stops at the first found and returns the result. + /// + private sealed class FileSystemBrowser : AsyncEnumerableBrowser> + { + /// + public FileSystemBrowser(RequestHeaderModel? header, IOptions options, + TimeProvider timeProvider) : base(header, options, timeProvider, + ObjectIds.ObjectsFolder, ObjectTypeIds.FileDirectoryType, stopWhenFound: true) + { + } + + /// + protected override IEnumerable> HandleError( + ServiceCallContext context, ServiceResultModel errorInfo) + { + yield return new ServiceResponse + { + ErrorInfo = errorInfo + }; + } + + /// + protected override IEnumerable> HandleMatching( + ServiceCallContext context, IReadOnlyList matching) + { + foreach (var match in matching) + { + yield return new ServiceResponse + { + Result = new FileSystemObjectModel + { + NodeId = Header.AsString(match.NodeId, + context.Session.MessageContext, Options), + Name = match.DisplayName + } + }; + } + } + } + + /// + /// File transfer stream + /// + private sealed class FileTransferStream : Stream + { + /// + public override bool CanRead + => !_mode.HasValue && _fileHandle.HasValue; + + /// + public override bool CanWrite + => _mode.HasValue && _fileHandle.HasValue; + + /// + public override long Length + => _fileInfo?.Size ?? Position; + + /// + public override long Position { get; set; } + + /// + public override bool CanSeek { get; } + + /// + public override bool CanTimeout => true; + + /// + public override int ReadTimeout + { + get => _header.OperationTimeout ?? 0; + set => _header.OperationTimeout = value; + } + + /// + public override int WriteTimeout + { + get => _header.OperationTimeout ?? 0; + set => _header.OperationTimeout = value; + } + + /// + /// Create stream + /// + /// + /// + /// + /// + /// + /// + /// + /// + public FileTransferStream(FileSystemServices outer, + ISessionHandle handle, RequestHeaderModel header, + NodeId nodeId, uint fileHandle, FileInfoModel? fileInfo, + uint bufferSize, FileWriteMode? mode = null) + { + _handle = handle; + _nodeId = nodeId; + _fileHandle = fileHandle; + _outer = outer; + _header = header; + _fileInfo = fileInfo; + _bufferSize = bufferSize; + _mode = mode; + + if (mode == FileWriteMode.Append) + { + Position = Length; + } + else + { + Position = 0; + } + + CanSeek = false; + } + + /// + /// Open stream + /// + /// + /// + /// + /// + /// + /// + /// + public static async Task<(Stream?, ServiceResultModel?)> OpenAsync( + FileSystemServices outer, T endpoint, RequestHeaderModel header, + FileSystemObjectModel file, FileWriteMode? mode = null, + CancellationToken ct = default) + { + var handle = await outer._client.AcquireSessionAsync(endpoint, header, + ct).ConfigureAwait(false); + var closeHandle = handle; + try + { + var (nodeId, argInfo) = await outer.GetFileSystemNodeIdAsync(handle.Session, + header, file, ct).ConfigureAwait(false); + if (argInfo != null) + { + return (null, argInfo); + } + + var (fileInfo, errorInfo) = await handle.Session.GetFileInfoAsync( + header.ToRequestHeader(outer._timeProvider), + nodeId, ct).ConfigureAwait(false); + + var tryCreate = errorInfo != null; + if (errorInfo != null) + { + // There should be file info + return (null, errorInfo); + } + if (mode != null && fileInfo?.Writable == false) + { + return (null, new ServiceResultModel + { + StatusCode = StatusCodes.BadNotWritable, + ErrorMessage = "File is not writable." + }); + } + + var bufferSize = fileInfo?.MaxBufferSize; + if (bufferSize == null) + { + var caps = await handle.Session.GetServerCapabilitiesAsync( + NamespaceFormat.Index, ct).ConfigureAwait(false); + bufferSize = caps.OperationLimits.MaxByteStringLength; + } + + var (fileHandle, errorInfo2) = await handle.Session.OpenAsync( + header.ToRequestHeader(outer._timeProvider), nodeId, mode switch + { + FileWriteMode.Create => 0x2 | 0x4, // Write bit plus erase + FileWriteMode.Append => 0x2 | 0x8, // Write bit plus append + FileWriteMode.Write => 0x2, // Write bit + _ => 0x1 // Read bit + }, ct).ConfigureAwait(false); + + if (errorInfo2 != null) + { + return (null, errorInfo2); + } + Debug.Assert(fileHandle.HasValue); + closeHandle = null; + return (new FileTransferStream(outer, handle, header, nodeId, + fileHandle.Value, fileInfo, bufferSize ?? 4096, mode), null); + } + finally + { + closeHandle?.Dispose(); + } + } + + /// + public override void Flush() + { + // No op + } + + /// + public override Task FlushAsync(CancellationToken cancellationToken) + { + // No op + return Task.CompletedTask; + } + + /// + public override long Seek(long offset, SeekOrigin origin) + { + throw new NotSupportedException(); // TODO + } + + /// + public override void SetLength(long value) + { + throw new NotSupportedException(); // TODO + } + + /// + public override async ValueTask ReadAsync(Memory buffer, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); + if (!CanRead) + { + throw new IOException("Cannot read from write-only stream"); + } + + var total = 0; + while (!_isEoS) + { + var readCount = (int)Math.Min(buffer.Length, _bufferSize); + if (readCount == 0) + { + break; + } + var (result, errorInfo) = await _handle.Session.ReadAsync( + _header.ToRequestHeader(_outer._timeProvider), _nodeId, + _fileHandle.Value, readCount, cancellationToken).ConfigureAwait(false); + if (errorInfo != null) + { + throw new IOException(errorInfo.ErrorMessage); + } + Debug.Assert(result != null); + + if (result.Length == 0) + { + // eof + _isEoS = true; + break; + } + + result.CopyTo(buffer.Span); + + Position += result.Length; + + total += result.Length; + if (buffer.Length == readCount) + { + break; + } + buffer = buffer[readCount..]; + } + return total; + } + + /// + public override int Read(byte[] buffer, int offset, int count) + { + var memory = new Memory(buffer); + return ReadAsync(memory.Slice(offset, count), default) + .AsTask().GetAwaiter().GetResult(); + } + + /// + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); + if (!CanWrite) + { + throw new IOException("Cannot write to read-only stream"); + } + while (true) + { + var writeCount = (int)Math.Min(buffer.Length, _bufferSize); + if (writeCount == 0) + { + break; + } + var errorInfo = await _handle.Session.WriteAsync( + _header.ToRequestHeader(_outer._timeProvider), _nodeId, + _fileHandle.Value, buffer[..writeCount].ToArray(), + cancellationToken).ConfigureAwait(false); + if (errorInfo != null) + { + throw new IOException(errorInfo.ErrorMessage); + } + + Position += writeCount; + + if (buffer.Length == writeCount) + { + break; + } + buffer = buffer[writeCount..]; + } + } + + /// + public override void Write(byte[] buffer, int offset, int count) + { + var memory = new ReadOnlyMemory(buffer); + WriteAsync(memory.Slice(offset, count), default) + .AsTask().GetAwaiter().GetResult(); + } + + /// + public override Task CopyToAsync(Stream destination, int bufferSize, + CancellationToken cancellationToken) + { + ValidateCopyToArguments(destination, bufferSize); + ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); + + if (!CanRead) + { + throw new IOException("Cannot read from write-only stream"); + } + + bufferSize = Math.Min(bufferSize, (int)_bufferSize); + return Core(this, destination, bufferSize, cancellationToken); + + static async Task Core(Stream source, Stream destination, + int bufferSize, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(bufferSize); + try + { + int bytesRead; + while ((bytesRead = await source.ReadAsync(new Memory(buffer), + cancellationToken).ConfigureAwait(false)) != 0) + { + await destination.WriteAsync( + new ReadOnlyMemory(buffer, 0, bytesRead), + cancellationToken).ConfigureAwait(false); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + } + + /// + public override void CopyTo(Stream destination, int bufferSize) + { + CopyToAsync(destination, bufferSize, default).GetAwaiter().GetResult(); + } + + /// + public async ValueTask CloseAsync(CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); + var errorInfo = await _handle.Session.CloseAsync( + _header.ToRequestHeader(_outer._timeProvider), _nodeId, + _fileHandle.Value, cancellationToken).ConfigureAwait(false); + if (errorInfo != null) + { + throw new IOException(errorInfo.ErrorMessage); + } + + // Closed - now release handle + _handle.Dispose(); + _fileHandle = null; + } + + /// + public override async ValueTask DisposeAsync() + { + if (_fileHandle.HasValue) + { + try + { + await CloseAsync(default).ConfigureAwait(false); + } + catch { } // Best effort closing + finally + { + if (_fileHandle.HasValue) + { + _handle.Dispose(); + _fileHandle = null; // Mark disposed + } + } + } + await base.DisposeAsync().ConfigureAwait(false); + } + + /// + protected override void Dispose(bool disposing) + { + if (disposing && _fileHandle.HasValue) + { + DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + base.Dispose(disposing); + } + + private readonly RequestHeaderModel _header; + private readonly ISessionHandle _handle; + private readonly NodeId _nodeId; + private readonly FileSystemServices _outer; + private readonly FileInfoModel? _fileInfo; + private readonly uint _bufferSize; + private readonly FileWriteMode? _mode; + private bool _isEoS; + private uint? _fileHandle; + } + + private readonly ActivitySource _activitySource = Diagnostics.NewActivitySource(); + private readonly IOptions _options; + private readonly TimeProvider _timeProvider; + private readonly IOpcUaClientManager _client; + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/HistoryServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/HistoryServices.cs index c25bd90965..d59dc3f598 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/HistoryServices.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/HistoryServices.cs @@ -10,6 +10,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Services using Azure.IIoT.OpcUa.Publisher.Stack.Extensions; using Azure.IIoT.OpcUa.Encoders; using Furly.Extensions.Serializers; + using Microsoft.Extensions.Options; using Opc.Ua; using Opc.Ua.Extensions; using System; @@ -28,12 +29,14 @@ public sealed class HistoryServices : IHistoryServices /// /// Create service /// + /// /// /// - public HistoryServices(INodeServicesInternal services, - TimeProvider? timeProvider = null) + public HistoryServices(IOptions options, + INodeServicesInternal services, TimeProvider? timeProvider = null) { _services = services; + _options = options; _timeProvider = timeProvider ?? TimeProvider.System; } @@ -222,7 +225,7 @@ public Task> HistoryReadProcessed { // TODO: Should be async! var capabilities = session.GetHistoryCapabilitiesAsync( - _services.GetNamespaceFormat(request.Header), ct).AsTask().Result; + request.Header.GetNamespaceFormat(_options), ct).AsTask().Result; if (capabilities?.AggregateFunctions != null && capabilities.AggregateFunctions.TryGetValue(aggregateType, out var id)) { @@ -678,6 +681,7 @@ static HistoricValueModel EncodeDataValue(IVariantEncoder codec, } private readonly INodeServicesInternal _services; + private readonly IOptions _options; private readonly TimeProvider _timeProvider; } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs index ae8f8a8e49..12c260706e 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs @@ -10,7 +10,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Services using Azure.IIoT.OpcUa.Publisher.Stack; using Azure.IIoT.OpcUa.Publisher.Stack.Extensions; using Azure.IIoT.OpcUa.Publisher.Stack.Models; - using Furly.Exceptions; using Furly.Extensions.Serializers; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -20,24 +19,21 @@ namespace Azure.IIoT.OpcUa.Publisher.Services using Opc.Ua.Extensions; using System; using System.Buffers; - using System.Collections; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; - using System.IO; using System.Linq; - using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; /// /// This class provides access to a servers address space providing node - /// and browse and base foundational services like file transfer services. - /// It uses the OPC ua client interface to access the server. + /// and browse services. It uses the OPC ua client interface to access + /// the server. /// /// - public sealed class NodeServices : INodeServices, - IFileSystemServices, INodeServicesInternal, IDisposable + public sealed class NodeServices : INodeServices, INodeServicesInternal, + IDisposable { /// /// Create node service @@ -69,8 +65,8 @@ public Task GetServerCapabilitiesAsync(T endpoint, RequestHeaderModel? header, CancellationToken ct) { return _client.ExecuteAsync(endpoint, async context => - await context.Session.GetServerCapabilitiesAsync(GetNamespaceFormat(header), - ct).ConfigureAwait(false), header, ct); + await context.Session.GetServerCapabilitiesAsync( + header.GetNamespaceFormat(_options), ct).ConfigureAwait(false), header, ct); } /// @@ -138,7 +134,7 @@ public async Task BrowseFirstAsync(T endpoint, var (node, nodeError) = await context.Session.ReadNodeAsync( request.Header.ToRequestHeader(_timeProvider), rootId, null, true, rawMode, - GetNamespaceFormat(request.Header), + request.Header.GetNamespaceFormat(_options), !excludeReferences ? references.Count != 0 : null, ct).ConfigureAwait(false); return new BrowseFirstResponseModel @@ -201,8 +197,9 @@ public async Task BrowseNextAsync(T endpoint, public IAsyncEnumerable BrowseAsync(T endpoint, BrowseStreamRequestModel request, CancellationToken ct) { - var stream = new BrowseStream(request, this, _activitySource, ct); - return _client.ExecuteAsync(endpoint, stream.Stack, request.Header, ct); + var stream = new BrowseStream(request, _options, _activitySource, _logger, + _timeProvider, ct); + return _client.ExecuteAsync(endpoint, stream, request.Header, ct); } /// @@ -270,21 +267,17 @@ public async Task GetMetadataAsync( using var trace = _activitySource.StartActivity("GetMetadata"); return await _client.ExecuteAsync(endpoint, async context => { - var nodeId = request.NodeId.ToNodeId(context.Session.MessageContext); - if (request.BrowsePath?.Count > 0) - { - nodeId = await ResolveBrowsePathToNodeAsync(context.Session, request.Header, - nodeId, request.BrowsePath.ToArray(), nameof(request.BrowsePath), - _timeProvider, context.Ct).ConfigureAwait(false); - } + var nodeId = await context.Session.ResolveNodeIdAsync(request.Header, request.NodeId, + request.BrowsePath, nameof(request.BrowsePath), _timeProvider, + context.Ct).ConfigureAwait(false); if (NodeId.IsNull(nodeId)) { throw new ArgumentException("Node id missing", nameof(request)); } var (node, errorInfo) = await context.Session.ReadNodeAsync( - request.Header.ToRequestHeader(_timeProvider), nodeId, GetNamespaceFormat(request.Header), - ct: context.Ct).ConfigureAwait(false); + request.Header.ToRequestHeader(_timeProvider), nodeId, + request.Header.GetNamespaceFormat(_options), ct: context.Ct).ConfigureAwait(false); if (errorInfo != null || node.NodeClass == null) { return new NodeMetadataResponseModel @@ -301,8 +294,8 @@ public async Task GetMetadataAsync( if (node.NodeClass == NodeClass.Method) { (methodMetadata, errorInfo) = await context.Session.GetMethodMetadataAsync( - request.Header.ToRequestHeader(_timeProvider), nodeId, GetNamespaceFormat(request.Header), - context.Ct).ConfigureAwait(false); + request.Header.ToRequestHeader(_timeProvider), nodeId, + request.Header.GetNamespaceFormat(_options), context.Ct).ConfigureAwait(false); if (errorInfo != null) { return new NodeMetadataResponseModel { ErrorInfo = errorInfo }; @@ -327,8 +320,8 @@ public async Task GetMetadataAsync( if (node.NodeClass == NodeClass.Variable) { (variableMetadata, errorInfo) = await context.Session.GetVariableMetadataAsync( - request.Header.ToRequestHeader(_timeProvider), nodeId, GetNamespaceFormat(request.Header), - context.Ct).ConfigureAwait(false); + request.Header.ToRequestHeader(_timeProvider), nodeId, + request.Header.GetNamespaceFormat(_options), context.Ct).ConfigureAwait(false); if (errorInfo != null) { return new NodeMetadataResponseModel { ErrorInfo = errorInfo }; @@ -340,8 +333,8 @@ public async Task GetMetadataAsync( if (typeId != nodeId) { (type, errorInfo) = await context.Session.ReadNodeAsync( - request.Header.ToRequestHeader(_timeProvider), typeId, GetNamespaceFormat(request.Header), - ct: context.Ct).ConfigureAwait(false); + request.Header.ToRequestHeader(_timeProvider), typeId, + request.Header.GetNamespaceFormat(_options), ct: context.Ct).ConfigureAwait(false); if (errorInfo != null || type.NodeClass == null) { return new NodeMetadataResponseModel @@ -364,7 +357,7 @@ await context.Session.CollectTypeHierarchyAsync(request.Header.ToRequestHeader(_ { errorInfo = await context.Session.CollectInstanceDeclarationsAsync( request.Header.ToRequestHeader(_timeProvider), (NodeId)superType.NodeId, - null, declarations, map, GetNamespaceFormat(request.Header), + null, declarations, map, request.Header.GetNamespaceFormat(_options), ct: context.Ct).ConfigureAwait(false); if (errorInfo != null) { @@ -376,7 +369,7 @@ await context.Session.CollectTypeHierarchyAsync(request.Header.ToRequestHeader(_ // collect the fields for the selected type. errorInfo = await context.Session.CollectInstanceDeclarationsAsync( request.Header.ToRequestHeader(_timeProvider), typeId, null, - declarations, map, GetNamespaceFormat(request.Header), + declarations, map, request.Header.GetNamespaceFormat(_options), ct: context.Ct).ConfigureAwait(false); } return new NodeMetadataResponseModel @@ -397,9 +390,11 @@ await context.Session.CollectTypeHierarchyAsync(request.Header.ToRequestHeader(_ Description = type.Description, TypeHierarchy = hierarchy.ConvertAll(e => new NodeModel { - NodeId = AsString(e.Item2.NodeId, context.Session.MessageContext, request.Header), + NodeId = request.Header.AsString(e.Item2.NodeId, + context.Session.MessageContext, _options), DisplayName = e.Item2.DisplayName.AsString(), - BrowseName = AsString(e.Item2.BrowseName, context.Session.MessageContext, request.Header), + BrowseName = request.Header.AsString(e.Item2.BrowseName, + context.Session.MessageContext, _options), NodeClass = e.Item2.NodeClass.ToServiceType() }), NodeType = GetNodeType(hierarchy @@ -462,7 +457,8 @@ public async Task CompileQueryAsync(T endpoint, return await _client.ExecuteAsync(endpoint, async context => { var parserContext = new SessionParserContext(context.Session, - request.Header.ToRequestHeader(_timeProvider), GetNamespaceFormat(request.Header)); + request.Header.ToRequestHeader(_timeProvider), + request.Header.GetNamespaceFormat(_options)); var eventFilter = await _parser.ParseEventFilterAsync(request.Query, parserContext, context.Ct).ConfigureAwait(false); return new QueryCompilationResponseModel @@ -495,14 +491,10 @@ public async Task GetMethodMetadataAsync( using var trace = _activitySource.StartActivity("GetMethodMetadata"); return await _client.ExecuteAsync(endpoint, async context => { - var methodId = request.MethodId.ToNodeId(context.Session.MessageContext); - if (request.MethodBrowsePath?.Count > 0) - { - // Browse from object id to method if possible - methodId = await NodeServices.ResolveBrowsePathToNodeAsync(context.Session, request.Header, - methodId, request.MethodBrowsePath.ToArray(), nameof(request.MethodBrowsePath), - _timeProvider, context.Ct).ConfigureAwait(false); - } + var methodId = await context.Session.ResolveNodeIdAsync(request.Header, request.MethodId, + request.MethodBrowsePath, nameof(request.MethodBrowsePath), _timeProvider, + context.Ct).ConfigureAwait(false); + if (NodeId.IsNull(methodId)) { throw new ArgumentException(nameof(request.MethodId)); @@ -546,8 +538,8 @@ public async Task GetMethodMetadataAsync( { if (nodeReference.ReferenceTypeId == ReferenceTypeIds.HasComponent) { - result.ObjectId = AsString(nodeReference.NodeId, - context.Session.MessageContext, request.Header); + result.ObjectId = request.Header.AsString(nodeReference.NodeId, + context.Session.MessageContext, _options); } continue; } @@ -577,7 +569,8 @@ public async Task GetMethodMetadataAsync( { var (dataTypeIdNode, errorInfo2) = await context.Session.ReadNodeAsync( request.Header.ToRequestHeader(_timeProvider), argument.DataType, null, - false, false, GetNamespaceFormat(request.Header), false, context.Ct).ConfigureAwait(false); + false, false, request.Header.GetNamespaceFormat(_options), + false, context.Ct).ConfigureAwait(false); var arg = new MethodMetadataArgumentModel { Name = argument.Name, @@ -629,13 +622,9 @@ public async Task MethodCallAsync(T endpoint, // * Like previously, but specify methodId and method browse path from it to a // real method node. // - var objectId = request.ObjectId.ToNodeId(context.Session.MessageContext); - if (request.ObjectBrowsePath?.Count > 0) - { - objectId = await ResolveBrowsePathToNodeAsync(context.Session, request.Header, - objectId, request.ObjectBrowsePath.ToArray(), nameof(request.ObjectBrowsePath), - _timeProvider, context.Ct).ConfigureAwait(false); - } + var objectId = await context.Session.ResolveNodeIdAsync(request.Header, request.ObjectId, + request.ObjectBrowsePath, nameof(request.ObjectBrowsePath), _timeProvider, + context.Ct).ConfigureAwait(false); if (NodeId.IsNull(objectId)) { throw new ArgumentException("Object id missing", nameof(request)); @@ -651,7 +640,7 @@ public async Task MethodCallAsync(T endpoint, throw new ArgumentException("Method id and object id missing", nameof(request)); } - methodId = await ResolveBrowsePathToNodeAsync(context.Session, request.Header, + methodId = await context.Session.ResolveBrowsePathToNodeAsync(request.Header, methodId, request.MethodBrowsePath.ToArray(), nameof(request.MethodBrowsePath), _timeProvider, context.Ct).ConfigureAwait(false); } @@ -827,13 +816,9 @@ public async Task ValueReadAsync(T endpoint, using var trace = _activitySource.StartActivity("ValueRead"); return await _client.ExecuteAsync(endpoint, async context => { - var readNode = request.NodeId.ToNodeId(context.Session.MessageContext); - if (request.BrowsePath?.Count > 0) - { - readNode = await ResolveBrowsePathToNodeAsync(context.Session, request.Header, - readNode, request.BrowsePath.ToArray(), nameof(request.BrowsePath), - _timeProvider, context.Ct).ConfigureAwait(false); - } + var readNode = await context.Session.ResolveNodeIdAsync(request.Header, request.NodeId, + request.BrowsePath, nameof(request.BrowsePath), _timeProvider, + context.Ct).ConfigureAwait(false); if (NodeId.IsNull(readNode)) { throw new ArgumentException("Node id missing", nameof(request)); @@ -904,13 +889,9 @@ public async Task ValueWriteAsync(T endpoint, using var trace = _activitySource.StartActivity("ValueWrite"); return await _client.ExecuteAsync(endpoint, async context => { - var writeNode = request.NodeId.ToNodeId(context.Session.MessageContext); - if (request.BrowsePath?.Count > 0) - { - writeNode = await ResolveBrowsePathToNodeAsync(context.Session, request.Header, - writeNode, request.BrowsePath.ToArray(), nameof(request.BrowsePath), - _timeProvider, context.Ct).ConfigureAwait(false); - } + var writeNode = await context.Session.ResolveNodeIdAsync(request.Header, request.NodeId, + request.BrowsePath, nameof(request.BrowsePath), _timeProvider, + context.Ct).ConfigureAwait(false); if (NodeId.IsNull(writeNode)) { throw new ArgumentException("Node id missing", nameof(request)); @@ -1059,7 +1040,7 @@ public Task HistoryGetServerCapabilitiesAsync( T endpoint, RequestHeaderModel? header, CancellationToken ct) { return _client.ExecuteAsync(endpoint, async context => - await context.Session.GetHistoryCapabilitiesAsync(GetNamespaceFormat(header), + await context.Session.GetHistoryCapabilitiesAsync(header.GetNamespaceFormat(_options), context.Ct).ConfigureAwait(false), header, ct); } @@ -1157,8 +1138,8 @@ public async Task HistoryGetConfigurationAsyn var children = new List(); config.AggregateFunctions.GetChildren(context.Session.SystemContext, children); var aggregateFunctions = children.OfType().ToDictionary( - c => AsString(c.BrowseName, context.Session.MessageContext, request.Header), - c => AsString(c.NodeId, context.Session.MessageContext, request.Header)); + c => request.Header.AsString(c.BrowseName, context.Session.MessageContext, _options), + c => request.Header.AsString(c.NodeId, context.Session.MessageContext, _options)); return new HistoryConfigurationResponseModel { Configuration = errorInfo != null ? null : new HistoryConfigurationModel @@ -1270,13 +1251,9 @@ public async Task> HistoryReadAsync { - var nodeId = request.NodeId.ToNodeId(context.Session.MessageContext); - if (request.BrowsePath?.Count > 0) - { - nodeId = await ResolveBrowsePathToNodeAsync(context.Session, - request.Header, nodeId, request.BrowsePath.ToArray(), - nameof(request.BrowsePath), _timeProvider, context.Ct).ConfigureAwait(false); - } + var nodeId = await context.Session.ResolveNodeIdAsync(request.Header, request.NodeId, + request.BrowsePath, nameof(request.BrowsePath), _timeProvider, + context.Ct).ConfigureAwait(false); if (NodeId.IsNull(nodeId)) { throw new ArgumentException("Bad node id", nameof(request)); @@ -1395,26 +1372,24 @@ public async Task HistoryUpdateAsync( using var trace = _activitySource.StartActivity("HistoryUpdate"); return await _client.ExecuteAsync(connectionId, async context => { - var nodeId = request.NodeId.ToNodeId(context.Session.MessageContext); - if (request.BrowsePath?.Count > 0) - { - nodeId = await ResolveBrowsePathToNodeAsync(context.Session, request.Header, - nodeId, request.BrowsePath.ToArray(), nameof(request.BrowsePath), - _timeProvider, context.Ct).ConfigureAwait(false); - } + var nodeId = await context.Session.ResolveNodeIdAsync(request.Header, + request.NodeId, request.BrowsePath, nameof(request.BrowsePath), + _timeProvider, context.Ct).ConfigureAwait(false); // Update the node id to target based on the request if (NodeId.IsNull(nodeId)) { throw new ArgumentException("Missing node id", nameof(request)); } - var details = await decode(nodeId, request.Details, context.Session).ConfigureAwait(false); + var details = await decode(nodeId, request.Details, + context.Session).ConfigureAwait(false); if (details == null) { throw new ArgumentException("Bad details", nameof(request)); } var updates = new ExtensionObjectCollection { details }; var response = await context.Session.Services.HistoryUpdateAsync( - request.Header.ToRequestHeader(_timeProvider), updates, context.Ct).ConfigureAwait(false); + request.Header.ToRequestHeader(_timeProvider), updates, + context.Ct).ConfigureAwait(false); var results = response.Validate(response.Results, r => r.StatusCode, response.DiagnosticInfos, updates); if (results.ErrorInfo != null) @@ -1431,432 +1406,6 @@ public async Task HistoryUpdateAsync( }, request.Header, ct).ConfigureAwait(false); } - /// - public async IAsyncEnumerable> GetFileSystemsAsync( - T endpoint, [EnumeratorCancellation] CancellationToken ct) - { - using var trace = _activitySource.StartActivity("GetFileSystems"); - var header = new RequestHeaderModel(); - - await Task.Delay(0, ct).ConfigureAwait(false); - yield break; - throw new NotImplementedException(); - } - - /// - public async Task>> GetDirectoriesAsync( - T endpoint, FileSystemObjectModel fileSystemOrDirectory, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("GetDirectories"); - var header = new RequestHeaderModel(); - - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return new ServiceResponse> - { - ErrorInfo = argInfo - }; - } - var (references, errorInfo) = await context.Session.FindAsync( - header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(), - ReferenceTypeIds.HasComponent, ct: context.Ct).ConfigureAwait(false); - if (errorInfo == null && references.Count > 0 && - references.All(r => r.ErrorInfo != null)) - { - errorInfo = references[0].ErrorInfo; - } - return new ServiceResponse> - { - ErrorInfo = errorInfo, - Result = references - .Where(r => r.TypeDefinition == Opc.Ua.ObjectTypes.FileDirectoryType && - r.ErrorInfo == null) - .Select(f => new FileSystemObjectModel - { - NodeId = AsString(f.Node, context.Session.MessageContext, header), - Name = f.Name.Name - }) - }; - }, header, ct).ConfigureAwait(false); - } - - /// - public async Task>> GetFilesAsync( - T endpoint, FileSystemObjectModel fileSystemOrDirectory, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("GetFiles"); - var header = new RequestHeaderModel(); - - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return new ServiceResponse> - { - ErrorInfo = argInfo - }; - } - - var (references, errorInfo) = await context.Session.FindAsync( - header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(), - ReferenceTypeIds.HasComponent, ct: context.Ct).ConfigureAwait(false); - if (errorInfo == null && references.Count > 0 && - references.All(r => r.ErrorInfo != null)) - { - errorInfo = references[0].ErrorInfo; - } - return new ServiceResponse> - { - ErrorInfo = errorInfo, - Result = references - .Where(r => r.TypeDefinition == Opc.Ua.ObjectTypes.FileType && - r.ErrorInfo == null) - .Select(f => new FileSystemObjectModel - { - NodeId = AsString(f.Node, context.Session.MessageContext, header), - Name = f.Name.Name - }) - }; - }, header, ct).ConfigureAwait(false); - } - - /// - public async Task> OpenReadAsync(T endpoint, - FileSystemObjectModel file, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("OpenRead"); - var header = new RequestHeaderModel(); - var (stream, errorInfo) = await FileTransferStream.OpenAsync(this, - endpoint, header, file, null, ct).ConfigureAwait(false); - return new ServiceResponse - { - ErrorInfo = errorInfo, - Result = stream - }; - } - - /// - public async Task> OpenWriteAsync(T endpoint, - FileSystemObjectModel file, FileWriteMode mode, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("OpenWrite"); - var header = new RequestHeaderModel(); - var (stream, errorInfo) = await FileTransferStream.OpenAsync(this, - endpoint, header, file, mode, ct).ConfigureAwait(false); - return new ServiceResponse - { - ErrorInfo = errorInfo, - Result = stream - }; - } - - /// - public async Task> CreateDirectoryAsync(T endpoint, - FileSystemObjectModel fileSystemOrDirectory, string name, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("CreateDirectory"); - var header = new RequestHeaderModel(); - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return new ServiceResponse { ErrorInfo = argInfo }; - } - var requests = new CallMethodRequestCollection - { - new CallMethodRequest - { - ObjectId = nodeId, - MethodId = Opc.Ua.MethodIds.FileDirectoryType_CreateDirectory, - InputArguments = new [] { new Variant(name) } - } - }; - // Call method - var response = await context.Session.Services.CallAsync(header - .ToRequestHeader(_timeProvider), requests, context.Ct).ConfigureAwait(false); - var results = response.Validate(response.Results, r => r.StatusCode, - response.DiagnosticInfos, requests); - if (results.ErrorInfo != null) - { - return new ServiceResponse { ErrorInfo = results.ErrorInfo }; - } - if (results[0].ErrorInfo != null || - results[0].Result?.OutputArguments == null || - results[0].Result.OutputArguments.Count == 0 || - results[0].Result.OutputArguments[0].Value is not NodeId result) - { - return new ServiceResponse - { - ErrorInfo = results[0].ErrorInfo ?? - new ServiceResultModel { ErrorMessage = "no node id returned" } - }; - } - return new ServiceResponse - { - Result = new FileSystemObjectModel - { - NodeId = AsString(result, context.Session.MessageContext, header), - Name = name - } - }; - }, header, ct).ConfigureAwait(false); - } - - /// - public async Task> CreateFileAsync(T endpoint, - FileSystemObjectModel fileSystemOrDirectory, string name, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("CreateFile"); - var header = new RequestHeaderModel(); - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, fileSystemOrDirectory, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return new ServiceResponse { ErrorInfo = argInfo }; - } - - var requests = new CallMethodRequestCollection - { - new CallMethodRequest - { - ObjectId = nodeId, - MethodId = Opc.Ua.MethodIds.FileDirectoryType_CreateFile, - InputArguments = new [] { new Variant(name), new Variant(false) } - } - }; - // Call method - var response = await context.Session.Services.CallAsync(header - .ToRequestHeader(_timeProvider), requests, context.Ct).ConfigureAwait(false); - - var results = response.Validate(response.Results, r => r.StatusCode, - response.DiagnosticInfos, requests); - if (results.ErrorInfo != null) - { - return new ServiceResponse { ErrorInfo = results.ErrorInfo }; - } - if (results[0].ErrorInfo != null || - results[0].Result?.OutputArguments == null || - results[0].Result.OutputArguments.Count == 0 || - results[0].Result.OutputArguments[0].Value is not NodeId result) - { - return new ServiceResponse - { - ErrorInfo = results[0].ErrorInfo ?? - new ServiceResultModel { ErrorMessage = "no node id returned" } - }; - } - return new ServiceResponse - { - Result = new FileSystemObjectModel - { - NodeId = AsString(result, context.Session.MessageContext, header), - Name = name - } - }; - }, header, ct).ConfigureAwait(false); - } - - /// - public async Task> GetParentAsync(T endpoint, - FileSystemObjectModel fileOrDirectoryObject, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("GetParent"); - var header = new RequestHeaderModel(); - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, fileOrDirectoryObject, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return new ServiceResponse { ErrorInfo = argInfo }; - } - - // Find parent - var (parents, argInfo2) = await context.Session.FindAsync( - header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(), - ReferenceTypeIds.HasComponent, isInverse: true, - maxResults: 1, ct: context.Ct).ConfigureAwait(false); - if (argInfo2 != null) - { - return new ServiceResponse { ErrorInfo = argInfo2 }; - } - var result = parents.Count > 0 ? parents[0] : default; - nodeId = result.Node; - if (NodeId.IsNull(nodeId) || - result.TypeDefinition != Opc.Ua.ObjectTypeIds.FileDirectoryType) - { - return new ServiceResponse - { - ErrorInfo = new ServiceResultModel - { - StatusCode = StatusCodes.BadNodeIdInvalid, - ErrorMessage = "Could not find a file directory object parent." - } - }; - } - return new ServiceResponse - { - Result = new FileSystemObjectModel - { - NodeId = AsString(nodeId, context.Session.MessageContext, header), - Name = result.Name.Name - } - }; - }, header, ct).ConfigureAwait(false); - } - - /// - public async Task DeleteFileSystemObjectAsync(T endpoint, - FileSystemObjectModel fileOrDirectoryObject, FileSystemObjectModel? parentFileSystemOrDirectory, - CancellationToken ct) - { - using var trace = _activitySource.StartActivity("DeleteFileSystemObject"); - var header = new RequestHeaderModel(); - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, fileOrDirectoryObject, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return argInfo; - } - - var targetId = nodeId; - if (parentFileSystemOrDirectory != null) - { - (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, parentFileSystemOrDirectory, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return argInfo; - } - } - else - { - // Find parent - var (parents, argInfo2) = await context.Session.FindAsync( - header.ToRequestHeader(_timeProvider), targetId.YieldReturn(), - ReferenceTypeIds.HasComponent, isInverse: true, - maxResults: 1, ct: context.Ct).ConfigureAwait(false); - if (argInfo2 != null) - { - return argInfo2; - } - var result = parents.Count > 0 ? parents[0] : default; - nodeId = result.Node; - if (NodeId.IsNull(nodeId) || - result.TypeDefinition != Opc.Ua.ObjectTypeIds.FileDirectoryType) - { - return new ServiceResultModel - { - StatusCode = StatusCodes.BadNodeIdInvalid, - ErrorMessage = "Could not find a file directory object parent." - }; - } - } - var requests = new CallMethodRequestCollection - { - new CallMethodRequest - { - ObjectId = nodeId, - MethodId = Opc.Ua.MethodIds.FileDirectoryType_DeleteFileSystemObject, - InputArguments = new [] { new Variant(targetId) } - } - }; - // Call method - var response = await context.Session.Services.CallAsync(header - .ToRequestHeader(_timeProvider), requests, context.Ct).ConfigureAwait(false); - - var results = response.Validate(response.Results, r => r.StatusCode, - response.DiagnosticInfos, requests); - return results.ErrorInfo ?? results[0].ErrorInfo ?? new ServiceResultModel(); - }, header, ct).ConfigureAwait(false); - } - - /// - public async Task> GetFileInfoAsync(T endpoint, - FileSystemObjectModel file, CancellationToken ct) - { - using var trace = _activitySource.StartActivity("GetFileInfo"); - var header = new RequestHeaderModel(); - return await _client.ExecuteAsync(endpoint, async context => - { - var (nodeId, argInfo) = await GetFileSystemNodeIdAsync(context.Session, - header, file, context.Ct).ConfigureAwait(false); - if (argInfo != null) - { - return new ServiceResponse { ErrorInfo = argInfo }; - } - var (fileInfo, errorInfo) = await context.Session.GetFileInfoAsync( - header.ToRequestHeader(_timeProvider), nodeId, context.Ct).ConfigureAwait(false); - return new ServiceResponse - { - ErrorInfo = errorInfo, - Result = fileInfo - }; - }, header, ct).ConfigureAwait(false); - } - - /// - public NamespaceFormat GetNamespaceFormat(RequestHeaderModel? header) - { - return header?.NamespaceFormat - ?? _options.Value.DefaultNamespaceFormat - ?? NamespaceFormat.Uri; - } - - /// - /// Get the node id for a file system object - /// - /// - /// - /// - /// - /// - /// - private async Task<(NodeId, ServiceResultModel?)> GetFileSystemNodeIdAsync(IOpcUaSession session, - RequestHeaderModel header, FileSystemObjectModel fileSystemObject, - CancellationToken ct) - { - var nodeId = fileSystemObject.NodeId.ToNodeId(session.MessageContext); - if (fileSystemObject.BrowsePath?.Count > 0) - { - if (nodeId is null) - { - nodeId = ObjectIds.RootFolder; - } - try - { - nodeId = await ResolveBrowsePathToNodeAsync(session, header, - nodeId, fileSystemObject.BrowsePath.Select(b => "" + b).ToArray(), - nameof(fileSystemObject.BrowsePath), _timeProvider, ct).ConfigureAwait(false); - } - catch (Exception ex) - { - return (NodeId.Null, ex.ToServiceResultModel()); - } - } - if (NodeId.IsNull(nodeId)) - { - return (NodeId.Null, new ServiceResultModel - { - StatusCode = StatusCodes.BadNodeIdInvalid, - ErrorMessage = "Invalid node id and browse path in file system object" - }); - } - return (nodeId, null); - } - /// /// Add references /// @@ -1884,7 +1433,7 @@ public NamespaceFormat GetNamespaceFormat(RequestHeaderModel? header) try { var nodeId = reference.NodeId.ToNodeId(session.MessageContext.NamespaceUris); - var id = AsString(nodeId, session.MessageContext, header); + var id = header.AsString(nodeId, session.MessageContext, _options); if (targetNodesOnly && result.Any(r => r.Target.NodeId == id)) { continue; @@ -1930,21 +1479,21 @@ await session.Services.BrowseNextAsync(header.ToRequestHeader(_timeProvider), } } var (model, _) = await session.ReadNodeAsync(header.ToRequestHeader(_timeProvider), nodeId, - reference.NodeClass, !readValues, rawMode, GetNamespaceFormat(header), + reference.NodeClass, !readValues, rawMode, header.GetNamespaceFormat(_options), children, ct).ConfigureAwait(false); if (rawMode) { model = model with { - BrowseName = AsString(reference.BrowseName, - session.MessageContext, header), + BrowseName = header.AsString(reference.BrowseName, + session.MessageContext, _options), DisplayName = reference.DisplayName?.ToString() }; } model = model with { - TypeDefinitionId = AsString(reference.TypeDefinition, - session.MessageContext, header) + TypeDefinitionId = header.AsString(reference.TypeDefinition, + session.MessageContext, _options) }; if (targetNodesOnly) { @@ -1953,8 +1502,8 @@ await session.Services.BrowseNextAsync(header.ToRequestHeader(_timeProvider), } result.Add(new NodeReferenceModel { - ReferenceTypeId = AsString(reference.ReferenceTypeId, - session.MessageContext, header), + ReferenceTypeId = header.AsString(reference.ReferenceTypeId, + session.MessageContext, _options), Direction = reference.IsForward ? BrowseDirection.Forward : BrowseDirection.Backward, Target = model @@ -1993,7 +1542,7 @@ private async Task AddTargetsToBrowseResultAsync(IOpcUaSession session, { var nodeId = target.TargetId.ToNodeId(session.MessageContext.NamespaceUris); var (model, _) = await session.ReadNodeAsync(header.ToRequestHeader(_timeProvider), - nodeId, null, !readValues, rawMode, GetNamespaceFormat(header), false, + nodeId, null, !readValues, rawMode, header.GetNamespaceFormat(_options), false, ct).ConfigureAwait(false); result.Add(new NodePathTargetModel { @@ -2013,60 +1562,6 @@ private async Task AddTargetsToBrowseResultAsync(IOpcUaSession session, } } - /// - /// Resolve provided path to node. - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - private static async Task ResolveBrowsePathToNodeAsync( - IOpcUaSession session, RequestHeaderModel? header, NodeId rootId, - string[] paths, string paramName, TimeProvider timeProvider, CancellationToken ct) - { - if (paths == null || paths.Length == 0) - { - return rootId; - } - if (NodeId.IsNull(rootId)) - { - rootId = ObjectIds.RootFolder; - } - var browsepaths = new BrowsePathCollection - { - new BrowsePath - { - StartingNode = rootId, - RelativePath = paths.ToRelativePath(session.MessageContext) - } - }; - var response = await session.Services.TranslateBrowsePathsToNodeIdsAsync( - header.ToRequestHeader(timeProvider), browsepaths, - ct).ConfigureAwait(false); - Debug.Assert(response != null); - var results = response.Validate(response.Results, r => r.StatusCode, - response.DiagnosticInfos, browsepaths); - var count = results[0].Result.Targets?.Count ?? 0; - if (count == 0) - { - throw new ResourceNotFoundException( - $"{paramName} did not resolve to any node."); - } - if (count != 1) - { - throw new ResourceConflictException( - $"{paramName} resolved to {count} nodes."); - } - return results[0].Result.Targets[0].TargetId - .ToNodeId(session.MessageContext.NamespaceUris); - } - /// /// Reads the first or last date of the archive /// (truncates milliseconds). @@ -2133,77 +1628,39 @@ await session.Services.HistoryReadAsync(header.ToRequestHeader(timeProvider), } } } - /// - /// Convert node id to string - /// - /// - /// - /// - /// - internal string AsString(NodeId nodeId, - IServiceMessageContext context, RequestHeaderModel? header) - { - return nodeId.AsString(context, GetNamespaceFormat(header)) ?? string.Empty; - } - - /// - /// Convert node id to string - /// - /// - /// - /// - /// - private string AsString(ExpandedNodeId nodeId, - IServiceMessageContext context, RequestHeaderModel? header) - { - return nodeId.AsString(context, GetNamespaceFormat(header)) ?? string.Empty; - } - - /// - /// Convert node id to string - /// - /// - /// - /// - /// - private string AsString(QualifiedName qualifiedName, - IServiceMessageContext context, RequestHeaderModel? header) - { - return qualifiedName.AsString(context, GetNamespaceFormat(header)) ?? string.Empty; - } /// /// Browse stream helper class /// - private sealed class BrowseStream + internal sealed class BrowseStream : AsyncEnumerableEnumerableStack { - /// - /// Browse stack - /// - public Stack>>> Stack - { get; } - /// /// Create browse stream helper /// /// - /// + /// /// + /// + /// /// - public BrowseStream(BrowseStreamRequestModel request, NodeServices outer, - ActivitySource activitySource, CancellationToken ct) + public BrowseStream(BrowseStreamRequestModel request, IOptions options, + ActivitySource activitySource, ILogger logger, TimeProvider timeProvider, + CancellationToken ct) { _activitySource = activitySource; _sw = Stopwatch.StartNew(); - _logger = outer._logger; - _timeProvider = outer._timeProvider; - _outer = outer; + _logger = logger; + _timeProvider = timeProvider; + _options = options; _ct = ct; _request = request; - Stack = new Stack>>>(); - Stack.Push(ReadNodeAsync); + } + + /// + public override void Reset() + { + base.Reset(); + Push(ReadNodeAsync); } /// @@ -2249,12 +1706,12 @@ private async ValueTask> ReadNodeAsync( var (node, errorInfo) = await context.Session.ReadNodeAsync( _request.Header.ToRequestHeader(_timeProvider), nodeId, - _outer.GetNamespaceFormat(_request.Header), null, + _request.Header.GetNamespaceFormat(_options), null, !(_request.ReadVariableValues ?? false), null, _ct).ConfigureAwait(false); _visited.Add(nodeId); // Mark as visited - var id = _outer.AsString(nodeId, context.Session.MessageContext, _request.Header); + var id = _request.Header.AsString(nodeId, context.Session.MessageContext, _options); if (id == null) { return Enumerable.Empty(); @@ -2270,10 +1727,10 @@ private async ValueTask> ReadNodeAsync( _nodes++; // Browse the node now - Stack.Push(context => BrowseAsync(context, id, nodeId)); + Push(context => BrowseAsync(context, id, nodeId)); // Read another node from the browse stack - Stack.Push(ReadNodeAsync); + Push(ReadNodeAsync); return chunk.YieldReturn(); } @@ -2298,8 +1755,10 @@ private async ValueTask> BrowseAsync( } } _view ??= _request.View.ToStackModel(context.Session.MessageContext); - var browseDescriptions = new BrowseDescriptionCollection { - new BrowseDescription { + var browseDescriptions = new BrowseDescriptionCollection + { + new BrowseDescription + { BrowseDirection = (_request.Direction ?? BrowseDirection.Both) .ToStackType(), IncludeSubtypes = !(_request.NoSubtypes ?? false), @@ -2331,7 +1790,12 @@ private async ValueTask> BrowseAsync( var continuation = results[0].Result.ContinuationPoint ?? Array.Empty(); if (continuation.Length > 0) { - Stack.Push(context => BrowseNextAsync(context, sourceId, continuation)); + Push(context => BrowseNextAsync(context, sourceId, continuation)); + } + else + { + // Read another node from the browse stack + Push(ReadNodeAsync); } return refs; } @@ -2371,7 +1835,12 @@ private async ValueTask> BrowseNextAsync( var continuation = results[0].Result.ContinuationPoint ?? Array.Empty(); if (continuation.Length > 0) { - Stack.Push(session => BrowseNextAsync(session, sourceId, continuation)); + Push(session => BrowseNextAsync(session, sourceId, continuation)); + } + else + { + // Read another node from the browse stack + Push(ReadNodeAsync); } return refs; } @@ -2433,8 +1902,8 @@ private IEnumerable CollectReferences( _references++; - var id = _outer.AsString(reference.NodeId, session.MessageContext, - _request.Header); + var id = _request.Header.AsString(reference.NodeId, session.MessageContext, + _options); if (id == null) { continue; @@ -2445,18 +1914,18 @@ private IEnumerable CollectReferences( ErrorInfo = errorInfo, Reference = new NodeReferenceModel { - ReferenceTypeId = _outer.AsString(reference.ReferenceTypeId, - session.MessageContext, _request.Header), + ReferenceTypeId = _request.Header.AsString(reference.ReferenceTypeId, + session.MessageContext, _options), Direction = reference.IsForward ? BrowseDirection.Forward : BrowseDirection.Backward, Target = new NodeModel { NodeId = id, DisplayName = reference.DisplayName?.ToString(), - TypeDefinitionId = _outer.AsString(reference.TypeDefinition, - session.MessageContext, _request.Header), - BrowseName = _outer.AsString(reference.BrowseName, - session.MessageContext, _request.Header) + TypeDefinitionId = _request.Header.AsString(reference.TypeDefinition, + session.MessageContext, _options), + BrowseName = _request.Header.AsString(reference.BrowseName, + session.MessageContext, _options) } } }; @@ -2474,398 +1943,16 @@ private IEnumerable CollectReferences( private readonly BrowseStreamRequestModel _request; private readonly ILogger _logger; private readonly TimeProvider _timeProvider; - private readonly NodeServices _outer; + private readonly IOptions _options; private readonly CancellationToken _ct; private readonly ActivitySource _activitySource; } - /// - /// File transfer stream - /// - private class FileTransferStream : Stream - { - /// - public override bool CanRead - => !_mode.HasValue && _fileHandle.HasValue; - - /// - public override bool CanWrite - => _mode.HasValue && _fileHandle.HasValue; - - /// - public override long Length - => _fileInfo?.Size ?? Position; - - /// - public override long Position { get; set; } - - /// - public override bool CanSeek { get; } - - /// - public override bool CanTimeout => true; - - /// - public override int ReadTimeout - { - get => _header.OperationTimeout ?? 0; - set => _header.OperationTimeout = value; - } - - /// - public override int WriteTimeout - { - get => _header.OperationTimeout ?? 0; - set => _header.OperationTimeout = value; - } - - /// - /// Create stream - /// - /// - /// - /// - /// - /// - /// - /// - /// - public FileTransferStream(NodeServices outer, - ISessionHandle handle, RequestHeaderModel header, - NodeId nodeId, uint fileHandle, FileInfoModel? fileInfo, - uint bufferSize, FileWriteMode? mode = null) - { - _handle = handle; - _nodeId = nodeId; - _fileHandle = fileHandle; - _outer = outer; - _header = header; - _fileInfo = fileInfo; - _bufferSize = bufferSize; - _mode = mode; - - if (mode == FileWriteMode.Append) - { - Position = Length; - } - else - { - Position = 0; - } - - CanSeek = false; - } - - /// - /// Open stream - /// - /// - /// - /// - /// - /// - /// - /// - public static async Task<(Stream?, ServiceResultModel?)> OpenAsync( - NodeServices outer, T endpoint, RequestHeaderModel header, - FileSystemObjectModel file, FileWriteMode? mode = null, - CancellationToken ct = default) - { - var handle = await outer._client.AcquireSessionAsync(endpoint, header, - ct).ConfigureAwait(false); - var closeHandle = handle; - try - { - var (nodeId, argInfo) = await outer.GetFileSystemNodeIdAsync(handle.Session, - header, file, ct).ConfigureAwait(false); - if (argInfo != null) - { - return (null, argInfo); - } - - var (fileInfo, errorInfo) = await handle.Session.GetFileInfoAsync( - header.ToRequestHeader(outer._timeProvider), - nodeId, ct).ConfigureAwait(false); - - var tryCreate = errorInfo != null; - if (errorInfo != null) - { - // There should be file info - return (null, errorInfo); - } - if (mode != null && fileInfo?.Writable == false) - { - return (null, new ServiceResultModel - { - StatusCode = StatusCodes.BadNotWritable, - ErrorMessage = "File is not writable." - }); - } - - var bufferSize = fileInfo?.MaxBufferSize; - if (bufferSize == null) - { - var caps = await handle.Session.GetServerCapabilitiesAsync( - NamespaceFormat.Index, ct).ConfigureAwait(false); - bufferSize = caps.OperationLimits.MaxByteStringLength; - } - - var (fileHandle, errorInfo2) = await handle.Session.OpenAsync( - header.ToRequestHeader(outer._timeProvider), nodeId, mode switch - { - FileWriteMode.Create => 0x2 | 0x4, // Write bit plus erase - FileWriteMode.Append => 0x2 | 0x8, // Write bit plus append - FileWriteMode.Write => 0x2, // Write bit - _ => 0x1 // Read bit - }, ct).ConfigureAwait(false); - - if (errorInfo2 != null) - { - return (null, errorInfo2); - } - Debug.Assert(fileHandle.HasValue); - closeHandle = null; - return (new FileTransferStream(outer, handle, header, nodeId, - fileHandle.Value, fileInfo, bufferSize ?? 4096, mode), null); - } - finally - { - closeHandle?.Dispose(); - } - } - - /// - public override void Flush() - { - // No op - } - - /// - public override Task FlushAsync(CancellationToken cancellationToken) - { - // No op - return Task.CompletedTask; - } - - /// - public override long Seek(long offset, SeekOrigin origin) - { - throw new NotSupportedException(); // TODO - } - - /// - public override void SetLength(long value) - { - throw new NotSupportedException(); // TODO - } - - /// - public override async ValueTask ReadAsync(Memory buffer, - CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); - if (!CanRead) - { - throw new IOException("Cannot read from write-only stream"); - } - - var total = 0; - while (!_isEoS) - { - var readCount = (int)Math.Min(buffer.Length, _bufferSize); - if (readCount == 0) - { - break; - } - var (result, errorInfo) = await _handle.Session.ReadAsync( - _header.ToRequestHeader(_outer._timeProvider), _nodeId, - _fileHandle.Value, readCount, cancellationToken).ConfigureAwait(false); - if (errorInfo != null) - { - throw new IOException(errorInfo.ErrorMessage); - } - Debug.Assert(result != null); - - if (result.Length == 0) - { - // eof - _isEoS = true; - break; - } - - result.CopyTo(buffer.Span); - - Position += result.Length; - - total += result.Length; - if (buffer.Length == readCount) - { - break; - } - buffer = buffer.Slice(readCount); - } - return total; - } - - /// - public override int Read(byte[] buffer, int offset, int count) - { - var memory = new Memory(buffer); - return ReadAsync(memory.Slice(offset, count), default) - .AsTask().GetAwaiter().GetResult(); - } - - /// - public override async ValueTask WriteAsync(ReadOnlyMemory buffer, - CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); - if (!CanWrite) - { - throw new IOException("Cannot write to read-only stream"); - } - while (true) - { - var writeCount = (int)Math.Min(buffer.Length, _bufferSize); - if (writeCount == 0) - { - break; - } - var errorInfo = await _handle.Session.WriteAsync( - _header.ToRequestHeader(_outer._timeProvider), _nodeId, - _fileHandle.Value, buffer.Slice(0, writeCount).ToArray(), - cancellationToken).ConfigureAwait(false); - if (errorInfo != null) - { - throw new IOException(errorInfo.ErrorMessage); - } - - Position += writeCount; - - if (buffer.Length == writeCount) - { - break; - } - buffer = buffer.Slice(writeCount); - } - } - - /// - public override void Write(byte[] buffer, int offset, int count) - { - var memory = new ReadOnlyMemory(buffer); - WriteAsync(memory.Slice(offset, count), default) - .AsTask().GetAwaiter().GetResult(); - } - - /// - public override Task CopyToAsync(Stream destination, int bufferSize, - CancellationToken cancellationToken) - { - ValidateCopyToArguments(destination, bufferSize); - ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); - - if (!CanRead) - { - throw new IOException("Cannot read from write-only stream"); - } - - bufferSize = Math.Min(bufferSize, (int)_bufferSize); - return Core(this, destination, bufferSize, cancellationToken); - - static async Task Core(Stream source, Stream destination, - int bufferSize, CancellationToken cancellationToken) - { - byte[] buffer = ArrayPool.Shared.Rent(bufferSize); - try - { - int bytesRead; - while ((bytesRead = await source.ReadAsync(new Memory(buffer), - cancellationToken).ConfigureAwait(false)) != 0) - { - await destination.WriteAsync( - new ReadOnlyMemory(buffer, 0, bytesRead), - cancellationToken).ConfigureAwait(false); - } - } - finally - { - ArrayPool.Shared.Return(buffer); - } - } - } - - /// - public override void CopyTo(Stream destination, int bufferSize) - { - CopyToAsync(destination, bufferSize, default).GetAwaiter().GetResult(); - } - - /// - public async ValueTask CloseAsync(CancellationToken cancellationToken) - { - ObjectDisposedException.ThrowIf(!_fileHandle.HasValue, this); - var errorInfo = await _handle.Session.CloseAsync( - _header.ToRequestHeader(_outer._timeProvider), _nodeId, - _fileHandle.Value, cancellationToken).ConfigureAwait(false); - if (errorInfo != null) - { - throw new IOException(errorInfo.ErrorMessage); - } - - // Closed - now release handle - _handle.Dispose(); - _fileHandle = null; - } - - /// - public override async ValueTask DisposeAsync() - { - if (_fileHandle.HasValue) - { - try - { - await CloseAsync(default).ConfigureAwait(false); - } - catch { } // Best effort closing - finally - { - if (_fileHandle.HasValue) - { - _handle.Dispose(); - _fileHandle = null; // Mark disposed - } - } - } - await base.DisposeAsync().ConfigureAwait(false); - } - - /// - protected override void Dispose(bool disposing) - { - if (disposing && _fileHandle.HasValue) - { - DisposeAsync().AsTask().GetAwaiter().GetResult(); - } - base.Dispose(disposing); - } - - private readonly RequestHeaderModel _header; - private readonly ISessionHandle _handle; - private readonly NodeId _nodeId; - private readonly NodeServices _outer; - private readonly FileInfoModel? _fileInfo; - private readonly uint _bufferSize; - private readonly FileWriteMode? _mode; - private bool _isEoS; - private uint? _fileHandle; - } - private readonly ActivitySource _activitySource = Diagnostics.NewActivitySource(); - private readonly ILogger _logger; private readonly IOptions _options; private readonly TimeProvider _timeProvider; private readonly IFilterParser _parser; private readonly IOpcUaClientManager _client; + private readonly ILogger _logger; } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublishedNodesJsonServices.cs similarity index 99% rename from src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs rename to src/Azure.IIoT.OpcUa.Publisher/src/Services/PublishedNodesJsonServices.cs index 1405254037..f9030fc395 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherConfigurationService.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublishedNodesJsonServices.cs @@ -29,8 +29,8 @@ namespace Azure.IIoT.OpcUa.Publisher.Services /// Provides configuration services for publisher using either published nodes /// configuration update or api services. /// - public sealed class PublisherConfigurationService : IConfigurationServices, - IAwaitable, IAsyncDisposable, IDisposable + public sealed class PublishedNodesJsonServices : IPublishedNodesServices, + IAwaitable, IAsyncDisposable, IDisposable { /// /// Create publisher configuration services @@ -42,8 +42,8 @@ public sealed class PublisherConfigurationService : IConfigurationServices, /// /// /// - public PublisherConfigurationService(PublishedNodesConverter publishedNodesJobConverter, - IPublisher publisherHost, ILogger logger, + public PublishedNodesJsonServices(PublishedNodesConverter publishedNodesJobConverter, + IPublisher publisherHost, ILogger logger, IStorageProvider publishedNodesProvider, IJsonSerializer jsonSerializer, IDiagnosticCollector? diagnostics = null, TimeProvider? timeProvider = null) { @@ -918,7 +918,7 @@ private IEnumerable GetCurrentPublishedNodes( } /// - public IAwaiter GetAwaiter() + public IAwaiter GetAwaiter() { return (_started?.Task ?? Task.CompletedTask).AsAwaiter(this); } @@ -980,7 +980,7 @@ private async Task ProcessFileChangesAsync() _publishedNodesProvider.Changed += OnChanged; var retryCount = 0; - await foreach (var clear in _fileChanges.Reader.ReadAllAsync()) + await foreach (var clear in _fileChanges.Reader.ReadAllAsync().ConfigureAwait(false)) { await _file.WaitAsync().ConfigureAwait(false); try diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherService.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherService.cs index 84875df17a..50b050ca6c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherService.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherService.cs @@ -149,7 +149,8 @@ private async Task RunAsync(CancellationToken ct) { try { - await foreach (var (task, changes) in _changeFeed.Reader.ReadAllAsync(default)) + await foreach (var (task, changes) in + _changeFeed.Reader.ReadAllAsync(default).ConfigureAwait(false)) { if (ct.IsCancellationRequested) { diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableBase.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableBase.cs new file mode 100644 index 0000000000..4d027b2b1b --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableBase.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Stack +{ + using Azure.IIoT.OpcUa.Publisher.Stack.Models; + using System; + using System.Collections.Generic; + using System.Linq; + using System.Threading.Tasks; + + /// + /// Async enumerable operation + /// + /// + public abstract class AsyncEnumerableBase + { + /// + /// Returns whether the operation is completed + /// + public abstract bool HasMore { get; } + + /// + /// Reset the enumeration + /// + public abstract void Reset(); + + /// + /// Execute + /// + /// + /// + public virtual async ValueTask> ExecuteAsync( + ServiceCallContext context) + { + var result = await RunAsync(context).ConfigureAwait(false); + return result.YieldReturn(); + } + + /// + /// Execute + /// + /// + /// + protected abstract ValueTask RunAsync(ServiceCallContext context); + + /// + /// Dispose + /// + public virtual void Dispose() + { + } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableStack.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableStack.cs new file mode 100644 index 0000000000..08e4b5c888 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/AsyncEnumerableStack.cs @@ -0,0 +1,104 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Stack +{ + using Azure.IIoT.OpcUa.Publisher.Stack.Models; + using System; + using System.Collections.Generic; + using System.Threading.Tasks; + + /// + /// Wraps a stack + /// + /// + public abstract class AsyncEnumerableEnumerableStack : AsyncEnumerableBase + { + /// + public override bool HasMore => _ops.Count > 0; + + /// + public override void Reset() + { + _ops.Clear(); + } + + /// + public override async ValueTask> ExecuteAsync(ServiceCallContext context) + { + var func = _ops.Pop(); + var cur = _ops.Count; + try + { + return await func.Invoke(context).ConfigureAwait(false); + } + catch + { + if (_ops.Count == cur) + { + _ops.Push(func); + } + throw; + } + } + + /// + protected void Push(Func>> value) + { + _ops.Push(value); + } + + /// + protected override ValueTask RunAsync(ServiceCallContext context) + { + throw new NotSupportedException(); + } + + private readonly Stack>>> _ops = new(); + } + + /// + /// Wraps a stack + /// + /// + public abstract class AsyncEnumerableStack : AsyncEnumerableBase + { + /// + public override bool HasMore => _ops.Count > 0; + + /// + public override void Reset() + { + _ops.Clear(); + } + + /// + protected void Push(Func> value) + { + _ops.Push(value); + } + + /// + protected override async ValueTask RunAsync(ServiceCallContext context) + { + var func = _ops.Pop(); + var cur = _ops.Count; + try + { + return await func.Invoke(context).ConfigureAwait(false); + } + catch + { + if (_ops.Count == cur) + { + _ops.Push(func); + } + throw; + } + } + + private readonly Stack>> _ops = new(); + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ServiceResultEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ServiceResultEx.cs index 5a58544904..67bd87182e 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ServiceResultEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ServiceResultEx.cs @@ -54,17 +54,20 @@ public static ServiceResultModel ToServiceResultModel(this Exception e) case ResourceNotFoundException: return Create(StatusCodes.BadNotFound, e.Message); case ResourceConflictException: - return Create(StatusCodes.BadDuplicateReferenceNotAllowed, e.Message); + return Create(StatusCodes.BadEntryExists, e.Message); + case ArgumentNullException: + return Create(StatusCodes.BadArgumentsMissing, e.Message); + case ArgumentException: + return Create(StatusCodes.BadInvalidArgument, e.Message); default: return Create(StatusCodes.Bad, e.Message); } - static ServiceResultModel Create(StatusCode code, string message) => - new ServiceResultModel - { - ErrorMessage = message, - SymbolicId = code.AsString(), - StatusCode = code.Code - }; + static ServiceResultModel Create(StatusCode code, string message) => new() + { + ErrorMessage = message, + SymbolicId = code.AsString(), + StatusCode = code.Code + }; } /// diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/SessionEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/SessionEx.cs index 05dd57a2ff..ea58612881 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/SessionEx.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/SessionEx.cs @@ -31,16 +31,16 @@ public static class SessionEx /// /// /// - /// + /// /// /// /// internal static async Task<(T?, ServiceResultModel?)> ReadAttributeAsync( - this IOpcUaSession session, RequestHeader header, NodeId nodeIds, + this IOpcUaSession session, RequestHeader header, NodeId nodeId, uint attributeId, CancellationToken ct = default) { var attributes = await session.ReadAttributeAsync(header, - nodeIds.YieldReturn(), attributeId, ct).ConfigureAwait(false); + nodeId.YieldReturn(), attributeId, ct).ConfigureAwait(false); return attributes.SingleOrDefault(); } @@ -247,9 +247,8 @@ internal static async Task> GetBrowsePathsFromRootAsyn while (searchContext.Count != 0) { - var results = session.BrowseAsync(requestHeader, null, - new BrowseDescriptionCollection(searchContext.Keys), ct).ConfigureAwait(false); - await foreach (var result in results) + await foreach (var result in session.BrowseAsync(requestHeader, null, + new BrowseDescriptionCollection(searchContext.Keys), ct).ConfigureAwait(false)) { if (result.Description == null) { @@ -536,9 +535,9 @@ internal static async Task> ReadNodeAttributesA foreach (var batch in objectsToBrowse.Batch(limits.GetMaxNodesPerBrowse())) { // Browse folders with objects and variables in it - var browseResults = session.BrowseAsync(requestHeader, null, - new BrowseDescriptionCollection(batch), ct).ConfigureAwait(false); - await foreach (var (description, references, errorInfo) in browseResults) + await foreach (var (description, references, errorInfo) in session.BrowseAsync( + requestHeader, null, new BrowseDescriptionCollection(batch), + ct).ConfigureAwait(false)) { var obj = (BaseObjectState?)description?.Handle; if (obj == null || references == null) @@ -700,8 +699,8 @@ internal static async Task CollectTypeHierarchyAsync(this IOpcUaSession session, ResultMask = (uint)BrowseResultMask.All } }; - var browseresults = session.BrowseAsync(requestHeader, null, nodeToBrowse, ct); - await foreach (var result in browseresults.ConfigureAwait(false)) + await foreach (var result in session.BrowseAsync(requestHeader, null, + nodeToBrowse, ct).ConfigureAwait(false)) { if (result.ErrorInfo != null) { @@ -1220,11 +1219,14 @@ await session.CollectInstanceDeclarationsAsync(requestHeader, /// Find results /// /// + /// /// /// + /// /// - internal record struct FindResult(QualifiedName Name, NodeId Node, - ExpandedNodeId TypeDefinition, ServiceResultModel? ErrorInfo = null); + internal record struct FindResult(QualifiedName Name, string? DisplayName, + NodeId Node, ExpandedNodeId TypeDefinition, Opc.Ua.NodeClass NodeClass, + ServiceResultModel? ErrorInfo = null); /// /// Finds the targets for the specified reference. @@ -1257,6 +1259,8 @@ internal record struct FindResult(QualifiedName Name, NodeId Node, NodeClassMask = nodeClassMask, ResultMask = (uint)BrowseResultMask.BrowseName | + (uint)BrowseResultMask.DisplayName | + (uint)BrowseResultMask.NodeClass | (uint)BrowseResultMask.TypeDefinition })); @@ -1278,8 +1282,8 @@ internal record struct FindResult(QualifiedName Name, NodeId Node, // check for error. if (result.ErrorInfo != null) { - targetIds.Add(new FindResult(QualifiedName.Null, NodeId.Null, - ExpandedNodeId.Null, result.ErrorInfo)); + targetIds.Add(new FindResult(QualifiedName.Null, null, NodeId.Null, + ExpandedNodeId.Null, Opc.Ua.NodeClass.Unspecified, result.ErrorInfo)); continue; } // check for continuation point. @@ -1310,8 +1314,8 @@ internal record struct FindResult(QualifiedName Name, NodeId Node, // check for error. if (result.ErrorInfo != null) { - targetIds.Add(new FindResult(QualifiedName.Null, NodeId.Null, - ExpandedNodeId.Null, result.ErrorInfo)); + targetIds.Add(new FindResult(QualifiedName.Null, null, NodeId.Null, + ExpandedNodeId.Null, Opc.Ua.NodeClass.Unspecified, result.ErrorInfo)); continue; } // check for continuation point. @@ -1349,13 +1353,13 @@ static bool Extract(List targetIds, ReferenceDescriptionCollection r if (NodeId.IsNull(reference.NodeId) || reference.NodeId.IsAbsolute) { - targetIds.Add(new FindResult(QualifiedName.Null, NodeId.Null, ExpandedNodeId.Null, + targetIds.Add(new FindResult(QualifiedName.Null, null, NodeId.Null, + ExpandedNodeId.Null, Opc.Ua.NodeClass.Unspecified, new ServiceResultModel { ErrorMessage = "Target node is null or absolute" })); continue; } - targetIds.Add(new FindResult(reference.BrowseName, - (NodeId)reference.NodeId, - reference.TypeDefinition)); + targetIds.Add(new FindResult(reference.BrowseName, reference.DisplayName?.Text, + (NodeId)reference.NodeId, reference.TypeDefinition, reference.NodeClass)); } return true; } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs index 7bd60621dc..366313929c 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs @@ -70,12 +70,12 @@ Task ExecuteAsync(T connection, /// /// /// - /// + /// /// /// /// IAsyncEnumerable ExecuteAsync(T connection, - Stack>>> stack, - RequestHeaderModel? header = null, CancellationToken ct = default); + AsyncEnumerableBase operation, RequestHeaderModel? header = null, + CancellationToken ct = default); } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.Browser.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.Browser.cs index 2c8360df6b..0191d2c1e9 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.Browser.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.Browser.cs @@ -112,7 +112,7 @@ private async Task RunAsync(CancellationToken ct) var sw = Stopwatch.StartNew(); try { - await foreach (var result in _channel.Reader.ReadAllAsync(ct)) + await foreach (var result in _channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) { if (!result) { diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs index fb3a24736f..711026381d 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClient.cs @@ -606,15 +606,14 @@ internal async Task RunAsync(Func> service, /// disconnected during an operation. /// /// - /// + /// /// /// /// /// /// /// - internal async IAsyncEnumerable RunAsync( - Stack>>> stack, + internal async IAsyncEnumerable RunAsync(AsyncEnumerableBase operation, int? connectTimeout, int? serviceCallTimeout, [EnumeratorCancellation] CancellationToken cancellationToken) { @@ -622,7 +621,8 @@ internal async IAsyncEnumerable RunAsync( using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); var ct = cts.Token; cts.CancelAfter(timeout); // wait max timeout on the reader lock/session - while (stack.Count > 0) + operation.Reset(); + while (operation.HasMore) { if (_disposed) { @@ -649,10 +649,7 @@ internal async IAsyncEnumerable RunAsync( var serviceTimeout = GetServiceCallTimeout(serviceCallTimeout); using var context = new ServiceCallContext(_session, serviceTimeout, ct: ct); cts.CancelAfter(serviceTimeout); - results = await stack.Peek()(context).ConfigureAwait(false); - - // Success - stack.Pop(); + results = await operation.ExecuteAsync(context).ConfigureAwait(false); } else { @@ -814,7 +811,8 @@ private async Task ManageSessionStateMachineAsync(CancellationToken ct) { try { - await foreach (var (trigger, context) in _channel.Reader.ReadAllAsync(ct)) + await foreach (var (trigger, context) in + _channel.Reader.ReadAllAsync(ct).ConfigureAwait(false)) { _logger.LogDebug("{Client}: Processing event {Event} in State {State}...", this, trigger, currentSessionState); @@ -1309,7 +1307,7 @@ private async ValueTask TryConnectAsync(CancellationToken ct) var securityProfile = _connection.Endpoint.SecurityPolicy; var endpointDescription = await SelectEndpointAsync(endpointUrl, - connection, securityMode, securityProfile).ConfigureAwait(false); + connection, securityMode, securityProfile, ct: ct).ConfigureAwait(false); if (endpointDescription == null) { _logger.LogWarning( @@ -1837,10 +1835,11 @@ private void NotifyConnectivityStateChange(EndpointConnectivityState state) /// /// /// + /// /// internal async Task SelectEndpointAsync(Uri? discoveryUrl, ITransportWaitingConnection? connection, SecurityMode securityMode, - string? securityPolicy, string? endpointUrl = null) + string? securityPolicy, string? endpointUrl = null, CancellationToken ct = default) { var endpointConfiguration = EndpointConfiguration.Create(); endpointConfiguration.OperationTimeout = @@ -1869,7 +1868,7 @@ private void NotifyConnectivityStateChange(EndpointConnectivityState state) DiscoveryClient.Create(_configuration, discoveryUrl, endpointConfiguration)) { var uri = new Uri(endpointUrl ?? client.Endpoint.EndpointUrl); - var endpoints = await client.GetEndpointsAsync(null).ConfigureAwait(false); + var endpoints = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false); discoveryUrl ??= uri; _logger.LogInformation("{Client}: Discovery endpoint {DiscoveryUrl} returned endpoints. " + diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs index 598af49547..d4f53e2838 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs @@ -301,12 +301,13 @@ public async Task ExecuteAsync(ConnectionModel connection, /// public IAsyncEnumerable ExecuteAsync(ConnectionModel connection, - Stack>>> stack, - RequestHeaderModel? header, CancellationToken ct) + AsyncEnumerableBase operation, RequestHeaderModel? header, + CancellationToken ct) { connection = UpdateConnectionFromHeader(connection, header); if (string.IsNullOrEmpty(connection.Endpoint?.Url)) { + operation.Dispose(); throw new ArgumentException("Missing endpoint url", nameof(connection)); } return ExecuteAsyncCore(ct); @@ -314,12 +315,19 @@ public IAsyncEnumerable ExecuteAsync(ConnectionModel connection, async IAsyncEnumerable ExecuteAsyncCore( [EnumeratorCancellation] CancellationToken ct) { - using var client = GetOrAddClient(connection); - await foreach (var result in client.RunAsync(stack, - header?.ConnectTimeout, header?.ServiceCallTimeout, - ct).ConfigureAwait(false)) + try + { + using var client = GetOrAddClient(connection); + await foreach (var result in client.RunAsync(operation, + header?.ConnectTimeout, header?.ServiceCallTimeout, + ct).ConfigureAwait(false)) + { + yield return result; + } + } + finally { - yield return result; + operation.Dispose(); } } } diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs index e99d3a6232..fe5b844778 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Condition.cs @@ -442,7 +442,7 @@ private void EnableConditionTimer() } if (_conditionTimer == null) { - _conditionTimer = new TimerEx(TimeProvider); + _conditionTimer = new(TimeProvider); _conditionTimer.AutoReset = false; _conditionTimer.Elapsed += OnConditionTimerElapsed; _logger.LogDebug("Re-enabled condition timer."); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs index e90de59e49..20238ad9fd 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.Heartbeat.cs @@ -363,7 +363,7 @@ private void EnableHeartbeatTimer() } if (_heartbeatTimer == null) { - _heartbeatTimer = new TimerEx(TimeProvider); + _heartbeatTimer = new(TimeProvider); _heartbeatTimer.AutoReset = true; _heartbeatTimer.Elapsed += SendHeartbeatNotifications; _logger.LogDebug("Re-enable heartbeat timer"); diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs index 6b123e331b..b7dbc15601 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSession.cs @@ -23,7 +23,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack.Services using System.Security.Cryptography.X509Certificates; using System.Threading; using System.Threading.Tasks; - using Microsoft.Azure.Devices.Shared; /// /// OPC UA session extends the SDK session @@ -798,9 +797,8 @@ private void Initialize() } List? subscriptions = null; - var subscriptionDiagnosticsArray = response.Results[1].Value as ExtensionObject[]; if (!ServiceResult.IsBad(response.Results[1].StatusCode) && - subscriptionDiagnosticsArray != null) + response.Results[1].Value is ExtensionObject[] subscriptionDiagnosticsArray) { subscriptions = subscriptionDiagnosticsArray .Select(o => o.Body) diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs index 4e98725fce..a77e503a39 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs @@ -441,28 +441,7 @@ public IEnumerable ToWriterGroups(IEnumerable - /// Convert to credential model + /// Convert to credential model and take into account backwards compatibility + /// by using the crypto provider to decrypt encrypted credentials. /// /// - private async Task ToCredentialAsync(PublishedNodesEntryModel entry) + private CredentialModel ToCredential(PublishedNodesEntryModel entry) { switch (entry.OpcAuthenticationMode) { @@ -832,8 +812,9 @@ private async Task ToCredentialAsync(PublishedNodesEntryModel e { if (_cryptoProvider != null) { - var userBytes = await _cryptoProvider.DecryptAsync(kInitializationVector, - Convert.FromBase64String(entry.EncryptedAuthUsername)).ConfigureAwait(false); + var userBytes = _cryptoProvider.DecryptAsync(kInitializationVector, + Convert.FromBase64String(entry.EncryptedAuthUsername)) + .AsTask().GetAwaiter().GetResult(); user = Encoding.UTF8.GetString(userBytes.Span); } else @@ -852,8 +833,9 @@ private async Task ToCredentialAsync(PublishedNodesEntryModel e { if (_cryptoProvider != null) { - var passwordBytes = await _cryptoProvider.DecryptAsync(kInitializationVector, - Convert.FromBase64String(entry.EncryptedAuthPassword)).ConfigureAwait(false); + var passwordBytes = _cryptoProvider.DecryptAsync(kInitializationVector, + Convert.FromBase64String(entry.EncryptedAuthPassword)) + .AsTask().GetAwaiter().GetResult(); password = Encoding.UTF8.GetString(passwordBytes.Span); } else @@ -896,7 +878,7 @@ private async Task ToCredentialAsync(PublishedNodesEntryModel e }; } - private static readonly OpcNodeModel kDummyEntry = new OpcNodeModel(); + private static readonly OpcNodeModel kDummyEntry = new(); private readonly bool _forceCredentialEncryption; private readonly int _scaleTestCount; private readonly int _maxNodesPerDataSet; diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs index 4807839743..418b2719f4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs @@ -12,28 +12,24 @@ namespace Azure.IIoT.OpcUa.Publisher.Tests.Services.FileSystem using Microsoft.Extensions.Configuration; using System.Threading.Tasks; using Xunit; - using Xunit.Abstractions; [Collection(FileCollection.Name)] public class BrowseTests { - public BrowseTests(FileSystemServer server, ITestOutputHelper output) + public BrowseTests(FileSystemServer server) { _server = server; - _output = output; } private BrowseTests GetTests() { return new BrowseTests( - () => new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), + () => new FileSystemServices(_server.Client, new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()), _server.GetConnection(), _server.TempPath); } private readonly FileSystemServer _server; - private readonly ITestOutputHelper _output; [Fact] public Task GetFileSystemsTest1Async() diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs index a53a7745b8..1141920317 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs @@ -12,28 +12,24 @@ namespace Azure.IIoT.OpcUa.Publisher.Tests.Services.FileSystem using Microsoft.Extensions.Configuration; using System.Threading.Tasks; using Xunit; - using Xunit.Abstractions; [Collection(FileCollection.Name)] public class OperationsTests { - public OperationsTests(FileSystemServer server, ITestOutputHelper output) + public OperationsTests(FileSystemServer server) { _server = server; - _output = output; } private OperationsTests GetTests() { return new OperationsTests( - () => new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), + () => new FileSystemServices(_server.Client, new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()), _server.GetConnection(), _server.TempPath); } private readonly FileSystemServer _server; - private readonly ITestOutputHelper _output; [Fact] public Task CreateDirectoryTest1Async() diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs index 4a945ee338..62fce1176a 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs @@ -12,28 +12,24 @@ namespace Azure.IIoT.OpcUa.Publisher.Tests.Services.FileSystem using Microsoft.Extensions.Configuration; using System.Threading.Tasks; using Xunit; - using Xunit.Abstractions; [Collection(FileCollection.Name)] public class ReadTests { - public ReadTests(FileSystemServer server, ITestOutputHelper output) + public ReadTests(FileSystemServer server) { _server = server; - _output = output; } private ReadTests GetTests() { return new ReadTests( - () => new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), + () => new FileSystemServices(_server.Client, new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()), _server.GetConnection(), _server.TempPath); } private readonly FileSystemServer _server; - private readonly ITestOutputHelper _output; [Fact] public Task ReadFileTest0Async() diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.cs index 6b883943cb..32acbe8402 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.cs @@ -12,28 +12,24 @@ namespace Azure.IIoT.OpcUa.Publisher.Tests.Services.FileSystem using Microsoft.Extensions.Configuration; using System.Threading.Tasks; using Xunit; - using Xunit.Abstractions; [Collection(FileCollection.Name)] public class WriteTests { - public WriteTests(FileSystemServer server, ITestOutputHelper output) + public WriteTests(FileSystemServer server) { _server = server; - _output = output; } private WriteTests GetTests() { return new WriteTests( - () => new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), + () => new FileSystemServices(_server.Client, new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()), _server.GetConnection(), _server.TempPath); } private readonly FileSystemServer _server; - private readonly ITestOutputHelper _output; [Fact] public Task WriteFileTest0Async() diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadAtTimesTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadAtTimesTests.cs index 28fe2cbc91..22e78c23a4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadAtTimesTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadAtTimesTests.cs @@ -27,9 +27,10 @@ private HistoryReadValuesAtTimesTests GetTests() { return new HistoryReadValuesAtTimesTests(_server, () => new HistoryServices( + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), - new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), + _output.BuildLoggerFor>(Logging.Level), + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), _server.GetConnection()); } diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadModifiedTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadModifiedTests.cs index f42143746b..7ee0477c2b 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadModifiedTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadModifiedTests.cs @@ -27,9 +27,10 @@ private HistoryReadValuesModifiedTests GetTests() { return new HistoryReadValuesModifiedTests(_server, () => new HistoryServices( + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), - new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), + _output.BuildLoggerFor>(Logging.Level), + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), _server.GetConnection()); } diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadProcessedTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadProcessedTests.cs index 1fe44bef38..34d0ca5fb4 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadProcessedTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadProcessedTests.cs @@ -27,9 +27,10 @@ private HistoryReadValuesProcessedTests GetTests() { return new HistoryReadValuesProcessedTests(_server, () => new HistoryServices( + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), - new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), + _output.BuildLoggerFor>(Logging.Level), + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), _server.GetConnection()); } diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadValuesTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadValuesTests.cs index 7046afa21d..c618c232c1 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadValuesTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/ReadValuesTests.cs @@ -27,9 +27,10 @@ private HistoryReadValuesTests GetTests() { return new HistoryReadValuesTests(_server, () => new HistoryServices( + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), - new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), + _output.BuildLoggerFor>(Logging.Level), + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), _server.GetConnection()); } diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/UpdateValuesTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/UpdateValuesTests.cs index 83ce597f19..9555f42043 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/UpdateValuesTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/HistoricalAccess/UpdateValuesTests.cs @@ -27,9 +27,10 @@ private HistoryUpdateValuesTests GetTests() { return new HistoryUpdateValuesTests( () => new HistoryServices( + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), new NodeServices(_server.Client, _server.Parser, - _output.BuildLoggerFor>(Logging.Level), - new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), + _output.BuildLoggerFor>(Logging.Level), + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions())), _server.GetConnection()); } diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublishedNodesJsonServicesTests.cs similarity index 99% rename from src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs rename to src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublishedNodesJsonServicesTests.cs index 30b06b5ddf..7eae22e8e0 100644 --- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublishedNodesJsonServicesTests.cs @@ -31,15 +31,15 @@ namespace Azure.IIoT.OpcUa.Publisher.Tests.Services using Xunit.Abstractions; /// - /// Tests the PublisherConfigService class + /// Tests the publisher configuration services /// - public class PublisherConfigServicesTests : TempFileProviderBase + public class PublishedNodesJsonServicesTests : TempFileProviderBase { /// /// Constructor that initializes common resources used by tests. /// /// - public PublisherConfigServicesTests(ITestOutputHelper output) + public PublishedNodesJsonServicesTests(ITestOutputHelper output) { _newtonSoftJsonSerializer = new NewtonsoftJsonSerializer(); _loggerFactory = LogFactory.Create(output, Logging.Config); @@ -87,12 +87,12 @@ protected override void Dispose(bool disposing) /// /// This method should be called only after content of _tempFile is set. /// - private PublisherConfigurationService InitPublisherConfigService() + private PublishedNodesJsonServices InitPublisherConfigService() { - var configService = new PublisherConfigurationService( + var configService = new PublishedNodesJsonServices( _publishedNodesJobConverter, _publisher, - _loggerFactory.CreateLogger(), + _loggerFactory.CreateLogger(), _publishedNodesProvider, _newtonSoftJsonSerializer ); @@ -1492,7 +1492,7 @@ public async Task TestAddOrUpdateEndpointsAddAndRemove() // Helper method. async Task AssertGetConfiguredNodesOnEndpointThrows( - PublisherConfigurationService publisherConfigurationService, + PublishedNodesJsonServices publisherConfigurationService, PublishedNodesEntryModel endpoint ) { diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests1.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests1.cs new file mode 100644 index 0000000000..ee192f6e75 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests1.cs @@ -0,0 +1,174 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Tests.Services.TestData +{ + using Azure.IIoT.OpcUa.Publisher.Services; + using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures; + using Azure.IIoT.OpcUa.Publisher.Testing.Tests; + using Microsoft.Extensions.Configuration; + using System.Threading.Tasks; + using Xunit; + using Xunit.Abstractions; + + [Collection(ReadCollection.Name)] + public class ExpandTests1 + { + public ExpandTests1(TestDataServer server, ITestOutputHelper output) + { + _server = server; + _output = output; + } + + private ConfigurationTests1 GetTests() + { + return new ConfigurationTests1(new ConfigurationServices(null!, _server.Client, + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), + _output.BuildLoggerFor(Logging.Level)), + _server.GetConnection()); + } + + private readonly TestDataServer _server; + private readonly ITestOutputHelper _output; + + [Fact] + public Task ExpandObjectWithBrowsePathTest1Async() + { + return GetTests().ExpandObjectWithBrowsePathTest1Async(); + } + + [Fact] + public Task ExpandObjectWithBrowsePathTest2Async() + { + return GetTests().ExpandObjectWithBrowsePathTest2Async(); + } + + [Fact] + public Task ExpandObjectTest1Async() + { + return GetTests().ExpandObjectTest1Async(); + } + + [Fact] + public Task ExpandObjectTest2Async() + { + return GetTests().ExpandObjectTest2Async(); + } + + [Fact] + public Task ExpandServerObjectTest1Async() + { + return GetTests().ExpandServerObjectTest1Async(); + } + + [Fact] + public Task ExpandServerObjectTest2Async() + { + return GetTests().ExpandServerObjectTest2Async(); + } + + [Fact] + public Task ExpandServerObjectTest3Async() + { + return GetTests().ExpandServerObjectTest3Async(); + } + + [Fact] + public Task ExpandServerObjectTest4Async() + { + return GetTests().ExpandServerObjectTest4Async(); + } + + [Fact] + public Task ExpandServerObjectTest5Async() + { + return GetTests().ExpandServerObjectTest5Async(); + } + + [Fact] + public Task ExpandBaseObjectTypeTest1Async() + { + return GetTests().ExpandBaseObjectTypeTest1Async(); + } + + [Fact] + public Task ExpandBaseObjectsAndObjectTypesTestAsync() + { + return GetTests().ExpandBaseObjectsAndObjectTypesTestAsync(); + } + + [Fact] + public Task ExpandVariablesTest1Async() + { + return GetTests().ExpandVariablesTest1Async(); + } + + [Fact] + public Task ExpandVariablesAndObjectsTest1Async() + { + return GetTests().ExpandVariablesAndObjectsTest1Async(); + } + + [Fact] + public Task ExpandVariableTypesTest1Async() + { + return GetTests().ExpandVariableTypesTest1Async(); + } + + [Fact] + public Task ExpandVariableTypesTest2Async() + { + return GetTests().ExpandVariableTypesTest2Async(); + } + + [Fact] + public Task ExpandVariableTypesTest3Async() + { + return GetTests().ExpandVariableTypesTest3Async(); + } + + [Fact] + public Task ExpandObjectWithNoObjectsTest1Async() + { + return GetTests().ExpandObjectWithNoObjectsTest1Async(); + } + + [Fact] + public Task ExpandObjectWithNoObjectsTest2Async() + { + return GetTests().ExpandObjectWithNoObjectsTest2Async(); + } + + [Fact] + public Task ExpandEmptyEntryTest1Async() + { + return GetTests().ExpandEmptyEntryTest1Async(); + } + + [Fact] + public Task ExpandEmptyEntryTest2Async() + { + return GetTests().ExpandEmptyEntryTest2Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest1Async() + { + return GetTests().ExpandBadNodeIdTest1Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest2Async() + { + return GetTests().ExpandBadNodeIdTest2Async(); + } + + [Fact] + public Task ExpandBadNodeIdTest3Async() + { + return GetTests().ExpandBadNodeIdTest3Async(); + } + } +} diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests2.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests2.cs new file mode 100644 index 0000000000..fc5fd1ed98 --- /dev/null +++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/TestData/ExpandTests2.cs @@ -0,0 +1,192 @@ +// ------------------------------------------------------------ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License (MIT). See License.txt in the repo root for license information. +// ------------------------------------------------------------ + +namespace Azure.IIoT.OpcUa.Publisher.Tests.Services.TestData +{ + using Azure.IIoT.OpcUa.Publisher.Services; + using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures; + using Azure.IIoT.OpcUa.Publisher.Testing.Tests; + using Microsoft.Extensions.Configuration; + using System.Threading.Tasks; + using Xunit; + using Xunit.Abstractions; + + [Collection(ReadCollection.Name)] + public class ExpandTests2 + { + public ExpandTests2(TestDataServer server, ITestOutputHelper output) + { + _server = server; + _output = output; + } + + private ConfigurationTests2 GetTests() + { + return new ConfigurationTests2(c => new ConfigurationServices(c, _server.Client, + new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions(), + _output.BuildLoggerFor(Logging.Level)), + _server.GetConnection()); + } + + private readonly TestDataServer _server; + private readonly ITestOutputHelper _output; + + [Fact] + public Task ConfigureFromObjectErrorTest1Async() + { + return GetTests().ConfigureFromObjectErrorTest1Async(); + } + + [Fact] + public Task ConfigureFromObjectErrorTest2Async() + { + return GetTests().ConfigureFromObjectErrorTest2Async(); + } + + [Fact] + public Task ConfigureFromObjectErrorTest3Async() + { + return GetTests().ConfigureFromObjectErrorTest3Async(); + } + + [Fact] + public Task ConfigureFromObjectWithBrowsePathTest1Async() + { + return GetTests().ConfigureFromObjectWithBrowsePathTest1Async(); + } + + [Fact] + public Task ConfigureFromObjectWithBrowsePathTest2Async() + { + return GetTests().ConfigureFromObjectWithBrowsePathTest2Async(); + } + + [Fact] + public Task ConfigureFromObjectTest1Async() + { + return GetTests().ConfigureFromObjectTest1Async(); + } + + [Fact] + public Task ConfigureFromObjectTest2Async() + { + return GetTests().ConfigureFromObjectTest2Async(); + } + + [Fact] + public Task ConfigureFromServerObjectTest1Async() + { + return GetTests().ConfigureFromServerObjectTest1Async(); + } + + [Fact] + public Task ConfigureFromServerObjectTest2Async() + { + return GetTests().ConfigureFromServerObjectTest2Async(); + } + + [Fact] + public Task ConfigureFromServerObjectTest3Async() + { + return GetTests().ConfigureFromServerObjectTest3Async(); + } + + [Fact] + public Task ConfigureFromServerObjectTest4Async() + { + return GetTests().ConfigureFromServerObjectTest4Async(); + } + + [Fact] + public Task ConfigureFromServerObjectTest5Async() + { + return GetTests().ConfigureFromServerObjectTest5Async(); + } + + [Fact] + public Task ConfigureFromBaseObjectTypeTest1Async() + { + return GetTests().ConfigureFromBaseObjectTypeTest1Async(); + } + + [Fact] + public Task ConfigureFromBaseObjectsAndObjectTypesTestAsync() + { + return GetTests().ConfigureFromBaseObjectsAndObjectTypesTestAsync(); + } + + [Fact] + public Task ConfigureFromVariablesTest1Async() + { + return GetTests().ConfigureFromVariablesTest1Async(); + } + + [Fact] + public Task ConfigureFromVariablesAndObjectsTest1Async() + { + return GetTests().ConfigureFromVariablesAndObjectsTest1Async(); + } + + [Fact] + public Task ConfigureFromVariableTypesTest1Async() + { + return GetTests().ConfigureFromVariableTypesTest1Async(); + } + + [Fact] + public Task ConfigureFromVariableTypesTest2Async() + { + return GetTests().ConfigureFromVariableTypesTest2Async(); + } + + [Fact] + public Task ConfigureFromVariableTypesTest3Async() + { + return GetTests().ConfigureFromVariableTypesTest3Async(); + } + + [Fact] + public Task ConfigureFromObjectWithNoObjectsTest1Async() + { + return GetTests().ConfigureFromObjectWithNoObjectsTest1Async(); + } + + [Fact] + public Task ConfigureFromObjectWithNoObjectsTest2Async() + { + return GetTests().ConfigureFromObjectWithNoObjectsTest2Async(); + } + + [Fact] + public Task ConfigureFromEmptyEntryTest1Async() + { + return GetTests().ConfigureFromEmptyEntryTest1Async(); + } + + [Fact] + public Task ConfigureFromEmptyEntryTest2Async() + { + return GetTests().ConfigureFromEmptyEntryTest2Async(); + } + + [Fact] + public Task ConfigureFromBadNodeIdTest1Async() + { + return GetTests().ConfigureFromBadNodeIdTest1Async(); + } + + [Fact] + public Task ConfigureFromBadNodeIdTest2Async() + { + return GetTests().ConfigureFromBadNodeIdTest2Async(); + } + + [Fact] + public Task ConfigureFromBadNodeIdTest3Async() + { + return GetTests().ConfigureFromBadNodeIdTest3Async(); + } + } +} diff --git a/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj b/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj index fee17e4c66..673383bbdf 100644 --- a/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj +++ b/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj @@ -6,10 +6,10 @@ enable - + - + diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs index 3db9578d83..c5eff0f2c2 100644 --- a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs +++ b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs @@ -135,6 +135,40 @@ public static bool HasSameWriterGroup(this PublishedNodesEntryModel model, return true; } + /// + /// Create connection from entry + /// + /// + /// + /// + public static ConnectionModel ToConnectionModel(this PublishedNodesEntryModel entry, + Func? credential = null) + { + credential ??= e => e.ToCredentialModel(); + return new ConnectionModel + { + Options = + (entry.UseReverseConnect == true ? + ConnectionOptions.UseReverseConnect : ConnectionOptions.None) | + (entry.DisableSubscriptionTransfer == true ? + ConnectionOptions.NoSubscriptionTransfer : ConnectionOptions.None) | + (entry.DumpConnectionDiagnostics == true ? + ConnectionOptions.DumpDiagnostics : ConnectionOptions.None), + Endpoint = new EndpointModel + { + Url = entry.EndpointUrl, + SecurityPolicy = entry.EndpointSecurityPolicy, + SecurityMode = entry.EndpointSecurityMode ?? + ((entry.UseSecurity ?? false) ? // Default for backcompat is no security + SecurityMode.NotNone : SecurityMode.None) + }, + User = + entry.OpcAuthenticationMode == OpcAuthenticationMode.UsernamePassword || + entry.OpcAuthenticationMode == OpcAuthenticationMode.Certificate ? + credential(entry) : null + }; + } + /// /// Create a new published nodes entry model. This is used only for the legacy /// API to start, stop, bulk and list nodes. If the connection model uses the @@ -161,7 +195,7 @@ public static bool HasSameWriterGroup(this PublishedNodesEntryModel model, UseSecurity = useSecurity, EndpointSecurityMode = !useSecurity.HasValue ? model.Endpoint?.SecurityMode : null, EndpointSecurityPolicy = model.Endpoint?.SecurityPolicy, - OpcAuthenticationMode = ToAuthenticationModel(model.User?.Type), + OpcAuthenticationMode = ToOpcAuthenticationMode(model.User?.Type), OpcAuthenticationPassword = model.User.GetPassword(), OpcAuthenticationUsername = model.User.GetUserName(), DataSetWriterGroup = model.Group, @@ -180,7 +214,7 @@ public static bool HasSameWriterGroup(this PublishedNodesEntryModel model, /// /// /// - internal static OpcAuthenticationMode ToAuthenticationModel(this CredentialType? type) + internal static OpcAuthenticationMode ToOpcAuthenticationMode(this CredentialType? type) { switch (type) { @@ -193,6 +227,38 @@ internal static OpcAuthenticationMode ToAuthenticationModel(this CredentialType? } } + /// + /// Convert to credential model + /// + /// + /// + internal static CredentialModel ToCredentialModel(this PublishedNodesEntryModel entry) + { + switch (entry.OpcAuthenticationMode) + { + case OpcAuthenticationMode.UsernamePassword: + case OpcAuthenticationMode.Certificate: + var user = entry.OpcAuthenticationUsername ?? string.Empty; + var password = entry.OpcAuthenticationPassword ?? string.Empty; + if ((!string.IsNullOrEmpty(entry.EncryptedAuthUsername) && string.IsNullOrEmpty(user)) || + (!string.IsNullOrEmpty(entry.EncryptedAuthPassword) && string.IsNullOrEmpty(password))) + { + throw new NotSupportedException("No crypto provider to decrypt encrypted username."); + } + return new CredentialModel + { + Type = entry.OpcAuthenticationMode == OpcAuthenticationMode.Certificate ? + CredentialType.X509Certificate : + CredentialType.UserName, + Value = new UserIdentityModel { User = user, Password = password } + }; + } + return new CredentialModel + { + Type = CredentialType.None + }; + } + /// /// Return a cloaked published nodes entry that can be used as lookup input to /// diff --git a/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj b/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj index 6d4ac8be84..4d8964801a 100644 --- a/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj +++ b/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj @@ -14,8 +14,8 @@ all runtime; build; native; contentfiles; analyzers - - + +