diff --git a/deploy/docker/docker-compose.yaml b/deploy/docker/docker-compose.yaml
index a5fd029165..1c98aa46bd 100644
--- a/deploy/docker/docker-compose.yaml
+++ b/deploy/docker/docker-compose.yaml
@@ -9,7 +9,7 @@ services:
- "50000:50000"
command: [
"--sph=True",
- "--spf=/shared/pn.json",
+ "--spf=/shared/opcplc.json",
"--pn=50000",
"--alm=True",
"--ses=True",
@@ -39,17 +39,16 @@ services:
"--di=60",
"--cl=5",
"--cto=30",
- "--mr=5000",
+ "--sto=1200",
"--rs",
"--dm=True",
- "--sqp",
"--cfa",
"--lfm=syslog",
"--pki=/shared/pki",
- "--pf=/shared/pn.json",
"--npd=${NODES_PER_DATASET:-5000}"
]
environment:
+ PublishedNodesFile: /shared/opcplc.json
ADDITIONAL_CONFIGURATION: /run/secrets/publisher-secrets
secrets:
- publisher-secrets
diff --git a/deploy/docker/with-localvolume.yaml b/deploy/docker/with-localvolume.yaml
new file mode 100644
index 0000000000..82d8250c8e
--- /dev/null
+++ b/deploy/docker/with-localvolume.yaml
@@ -0,0 +1,14 @@
+services:
+ publisher:
+ environment:
+ PublishedNodesFile: /shared/pn.json
+ UseFileChangePolling: True
+ volumes:
+ - shared:/shared:rw
+volumes:
+ shared:
+ driver: local
+ driver_opts:
+ type: none
+ device: c:\Shared
+ o: bind
\ No newline at end of file
diff --git a/deploy/iotedge/eflow-setup.ps1 b/deploy/iotedge/eflow-setup.ps1
index 1a83b0cd76..ed2090a726 100644
--- a/deploy/iotedge/eflow-setup.ps1
+++ b/deploy/iotedge/eflow-setup.ps1
@@ -29,7 +29,7 @@
param(
[string] $IotHubName,
- [string] $TenantId = "6e54c408-5edd-4f87-b3bb-360788b7ca18",
+ [string] $TenantId,
[string] $SubscriptionId,
[string] $SharedFolderPath,
[switch] $ProvisioningOnly,
@@ -45,6 +45,10 @@ $eflowMsiUri = "https://aka.ms/AzEFLOWMSI_1_4_LTS_X64"
$ErrorActionPreference = "Stop"
$path = Split-Path $script:MyInvocation.MyCommand.Path
+if ([string]::IsNullOrWhiteSpace($TenantId)) {
+ $TenantId = $env:AZURE_TENANT_ID
+}
+
$setupPath = Join-Path $path "eflow-setup"
if (!(Test-Path $setupPath)) {
New-Item -ItemType Directory -Path $setupPath | Out-Null
diff --git a/docs/opc-publisher/api.md b/docs/opc-publisher/api.md
index 8acc31747f..6447028357 100644
--- a/docs/opc-publisher/api.md
+++ b/docs/opc-publisher/api.md
@@ -1160,6 +1160,454 @@ Start server registration. The results of the registration are published as even
* `application/x-msgpack`
+
+### FileSystem
+This section lists the file transfer API provided by OPC Publisher providing
+ access to file transfer services to move files in and out of a server
+ using the File transfer specification.
+
+
+
+ The method name for all transports other than HTTP (which uses the shown
+ HTTP methods and resource uris) is the name of the subsection header.
+ To use the version specific method append "_V1" or "_V2" to the method
+ name.
+
+
+
+#### CreateDirectory
+```
+POST /v2/filesystem/create/directory/{name}
+```
+
+
+##### Description
+Create a new directory in an existing file system or directory on the server.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Path**|**name**
*required*|The name of the directory to create as child under the parent directory provided|string|
+|**Body**|**body**
*required*|The file system or directory object to create the directory in and the connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileSystemObjectModelServiceResponse](definitions.md#filesystemobjectmodelserviceresponse)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### CreateFile
+```
+POST /v2/filesystem/create/file/{name}
+```
+
+
+##### Description
+Create a new file in a directory or file system on the server
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Path**|**name**
*required*|The name of the file to create as child under the directory or filesystem provided|string|
+|**Body**|**body**
*required*|The file system or directory object to create the file in and the connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileSystemObjectModelServiceResponse](definitions.md#filesystemobjectmodelserviceresponse)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### DeleteFileSystemObject
+```
+POST /v2/filesystem/delete
+```
+
+
+##### Description
+Delete a file or directory in an existing file system on the server.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Body**|**body**
*required*|The file or directory object to delete and the connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[ServiceResultModel](definitions.md#serviceresultmodel)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### DeleteFileOrDirectory
+```
+POST /v2/filesystem/delete/{fileOrDirectoryNodeId}
+```
+
+
+##### Description
+Delete a file or directory in the specified directory or file system.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Path**|**fileOrDirectoryNodeId**
*required*|The node id of the file or directory to delete|string|
+|**Body**|**body**
*required*|The filesystem or directory object in which to delete the specified file or directory and the connection to use for the operation.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[ServiceResultModel](definitions.md#serviceresultmodel)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### Download
+```
+GET /v2/filesystem/download
+```
+
+
+##### Description
+Download a file from the server
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Header**|**x-ms-connection**
*required*|The connection information identifying the server to connect to perform the operation on. This is passed as json serialized via the header "x-ms-connection"|string|
+|**Header**|**x-ms-target**
*required*|The file object to upload. This is passed as json serialized via the header "x-ms-target"|string|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|No Content|
+|**400**|The passed in information is invalid|[ProblemDetails](definitions.md#problemdetails)|
+|**408**|The operation timed out.|[ProblemDetails](definitions.md#problemdetails)|
+|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)|
+
+
+##### Produces
+
+* `application/json`
+* `application/x-msgpack`
+
+
+
+#### GetFileInfo
+```
+POST /v2/filesystem/info/file
+```
+
+
+##### Description
+Gets the file information for a file on the server.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Body**|**body**
*required*|The file object and connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileInfoModelServiceResponse](definitions.md#fileinfomodelserviceresponse)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### GetFileSystems
+```
+POST /v2/filesystem/list
+```
+
+
+##### Description
+Gets all file systems of the server.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Body**|**body**
*required*|The connection information identifying the server to connect to perform the operation on.|[ConnectionModel](definitions.md#connectionmodel)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileSystemObjectModelServiceResponseIAsyncEnumerable](definitions.md#filesystemobjectmodelserviceresponseiasyncenumerable)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### GetDirectories
+```
+POST /v2/filesystem/list/directories
+```
+
+
+##### Description
+Gets all directories in a directory or file system
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Body**|**body**
*required*|The directory or filesystem object and connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileSystemObjectModelIEnumerableServiceResponse](definitions.md#filesystemobjectmodelienumerableserviceresponse)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### GetFiles
+```
+POST /v2/filesystem/list/files
+```
+
+
+##### Description
+Get files in a directory or file system on a server.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Body**|**body**
*required*|The directory or filesystem object and connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileSystemObjectModelIEnumerableServiceResponse](definitions.md#filesystemobjectmodelienumerableserviceresponse)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### GetParent
+```
+POST /v2/filesystem/parent
+```
+
+
+##### Description
+Gets the parent directory or filesystem of a file or directory.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Body**|**body**
*required*|The file or directory object and connection information identifying the server to connect to perform the operation on.|[FileSystemObjectModelRequestEnvelope](definitions.md#filesystemobjectmodelrequestenvelope)|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|[FileSystemObjectModelServiceResponse](definitions.md#filesystemobjectmodelserviceresponse)|
+|**400**|The passed in information is invalid|[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`
+
+
+
+#### Upload
+```
+POST /v2/filesystem/upload
+```
+
+
+##### Description
+Upload a file to the server.
+
+
+##### Parameters
+
+|Type|Name|Description|Schema|
+|---|---|---|---|
+|**Header**|**x-ms-connection**
*required*|The connection information identifying the server to connect to perform the operation on. This is passed as json serialized via the header "x-ms-connection"|string|
+|**Header**|**x-ms-mode**
*required*|The file write mode to use passed as header "x-ms-mode"|string|
+|**Header**|**x-ms-target**
*required*|The file object to upload. This is passed as json serialized via the header "x-ms-target"|string|
+
+
+##### Responses
+
+|HTTP Code|Description|Schema|
+|---|---|---|
+|**200**|The operation was successful or the response payload contains relevant error information.|No Content|
+|**400**|The passed in information is invalid|[ProblemDetails](definitions.md#problemdetails)|
+|**408**|The operation timed out.|[ProblemDetails](definitions.md#problemdetails)|
+|**500**|An unexpected error occurred|[ProblemDetails](definitions.md#problemdetails)|
+
+
+##### Produces
+
+* `application/json`
+* `application/x-msgpack`
+
+
### General
This section lists the general APi provided by OPC Publisher providing
diff --git a/docs/opc-publisher/definitions.md b/docs/opc-publisher/definitions.md
index 47cfa2da16..8046033c3b 100644
--- a/docs/opc-publisher/definitions.md
+++ b/docs/opc-publisher/definitions.md
@@ -687,6 +687,84 @@ Exception deviation type
*Type* : enum (AbsoluteValue, PercentOfValue, PercentOfRange, PercentOfEURange)
+
+### FileInfoModel
+File info
+
+
+|Name|Description|Schema|
+|---|---|---|
+|**lastModified**
*optional*|The time the file was last modified.|string (date-time)|
+|**maxBufferSize**
*optional*|The maximum number of bytes of
the read and write buffers.|integer (int64)|
+|**mimeType**
*optional*|The media type of the file based on RFC 2046.|string|
+|**openCount**
*optional*|The number of currently valid file handles on
the file.|integer (int32)|
+|**size**
*optional*|The size of the file in Bytes. When a file is
currently opened for write, the size might not be
accurate or available.|integer (int64)|
+|**writable**
*optional*|Whether the file is writable.|boolean|
+
+
+
+### FileInfoModelServiceResponse
+Response envelope
+
+
+|Name|Schema|
+|---|---|
+|**errorInfo**
*optional*|[ServiceResultModel](definitions.md#serviceresultmodel)|
+|**result**
*optional*|[FileInfoModel](definitions.md#fileinfomodel)|
+
+
+
+### FileSystemObjectModel
+File system object model
+
+
+|Name|Description|Schema|
+|---|---|---|
+|**browsePath**
*optional*|The browse path to the filesystem object|< string > array|
+|**name**
*optional*|The name of the filesystem object|string|
+|**nodeId**
*optional*|The node id of the filesystem object|string|
+
+
+
+### FileSystemObjectModelIEnumerableServiceResponse
+Response envelope
+
+
+|Name|Description|Schema|
+|---|---|---|
+|**errorInfo**
*optional*||[ServiceResultModel](definitions.md#serviceresultmodel)|
+|**result**
*optional*|Result|< [FileSystemObjectModel](definitions.md#filesystemobjectmodel) > array|
+
+
+
+### FileSystemObjectModelRequestEnvelope
+Wraps a request and a connection to bind to a
+body more easily for api that requires a
+connection endpoint
+
+
+|Name|Schema|
+|---|---|
+|**connection**
*required*|[ConnectionModel](definitions.md#connectionmodel)|
+|**request**
*optional*|[FileSystemObjectModel](definitions.md#filesystemobjectmodel)|
+
+
+
+### FileSystemObjectModelServiceResponse
+Response envelope
+
+
+|Name|Schema|
+|---|---|
+|**errorInfo**
*optional*|[ServiceResultModel](definitions.md#serviceresultmodel)|
+|**result**
*optional*|[FileSystemObjectModel](definitions.md#filesystemobjectmodel)|
+
+
+
+### FileSystemObjectModelServiceResponseIAsyncEnumerable
+*Type* : object
+
+
### FilterOperandModel
Filter operand
diff --git a/docs/opc-publisher/openapi.json b/docs/opc-publisher/openapi.json
index 0ae4e045cf..b06a870dcd 100644
--- a/docs/opc-publisher/openapi.json
+++ b/docs/opc-publisher/openapi.json
@@ -1510,6 +1510,635 @@
}
}
},
+ "/v2/filesystem/list": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "GetFileSystems",
+ "description": "Gets all file systems of the server.",
+ "operationId": "GetFileSystems",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/ConnectionModel"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelServiceResponseIAsyncEnumerable"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/list/directories": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "GetDirectories",
+ "description": "Gets all directories in a directory or file system",
+ "operationId": "GetDirectories",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The directory or filesystem object and connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelIEnumerableServiceResponse"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/list/files": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "GetFiles",
+ "description": "Get files in a directory or file system on a server.",
+ "operationId": "GetFiles",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The directory or filesystem object and connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelIEnumerableServiceResponse"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/parent": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "GetParent",
+ "description": "Gets the parent directory or filesystem of a file or directory.",
+ "operationId": "GetParent",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The file or directory object and connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelServiceResponse"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/info/file": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "GetFileInfo",
+ "description": "Gets the file information for a file on the server.",
+ "operationId": "GetFileInfo",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The file object and connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileInfoModelServiceResponse"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/create/file/{name}": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "CreateFile",
+ "description": "Create a new file in a directory or file system on the server",
+ "operationId": "CreateFile",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "description": "The name of the file to create as child under the directory or filesystem provided",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The file system or directory object to create the file in and the connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelServiceResponse"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/create/directory/{name}": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "CreateDirectory",
+ "description": "Create a new directory in an existing file system or directory on the server.",
+ "operationId": "CreateDirectory",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "name",
+ "description": "The name of the directory to create as child under the parent directory provided",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The file system or directory object to create the directory in and the connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelServiceResponse"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/delete": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "DeleteFileSystemObject",
+ "description": "Delete a file or directory in an existing file system on the server.",
+ "operationId": "DeleteFileSystemObject",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The file or directory object to delete and the connection information identifying the server to connect to perform the operation on.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/ServiceResultModel"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/delete/{fileOrDirectoryNodeId}": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "DeleteFileOrDirectory",
+ "description": "Delete a file or directory in the specified directory or file system.",
+ "operationId": "DeleteFileOrDirectory",
+ "consumes": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "path",
+ "name": "fileOrDirectoryNodeId",
+ "description": "The node id of the file or directory to delete",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "in": "body",
+ "name": "body",
+ "description": "The filesystem or directory object in which to delete the specified file or directory and the connection to use for the operation.",
+ "required": true,
+ "schema": {
+ "$ref": "#/definitions/FileSystemObjectModelRequestEnvelope"
+ }
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information.",
+ "schema": {
+ "$ref": "#/definitions/ServiceResultModel"
+ }
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/download": {
+ "get": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "Download",
+ "description": "Download a file from the server",
+ "operationId": "Download",
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "header",
+ "name": "x-ms-connection",
+ "description": "The connection information identifying the server to connect to perform the operation on. This is passed as json serialized via the header \"x-ms-connection\"",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "in": "header",
+ "name": "x-ms-target",
+ "description": "The file object to upload. This is passed as json serialized via the header \"x-ms-target\"",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information."
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/filesystem/upload": {
+ "post": {
+ "tags": [
+ "FileSystem"
+ ],
+ "summary": "Upload",
+ "description": "Upload a file to the server.",
+ "operationId": "Upload",
+ "produces": [
+ "application/json",
+ "application/x-msgpack"
+ ],
+ "parameters": [
+ {
+ "in": "header",
+ "name": "x-ms-connection",
+ "description": "The connection information identifying the server to connect to perform the operation on. This is passed as json serialized via the header \"x-ms-connection\"",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "in": "header",
+ "name": "x-ms-target",
+ "description": "The file object to upload. This is passed as json serialized via the header \"x-ms-target\"",
+ "required": true,
+ "type": "string"
+ },
+ {
+ "in": "header",
+ "name": "x-ms-mode",
+ "description": "The file write mode to use passed as header \"x-ms-mode\"",
+ "required": true,
+ "type": "string"
+ }
+ ],
+ "responses": {
+ "200": {
+ "description": "The operation was successful or the response payload contains relevant error information."
+ },
+ "400": {
+ "description": "The passed in information is invalid",
+ "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/capabilities": {
"post": {
"tags": [
@@ -5742,6 +6371,126 @@
"modelAsString": false
}
},
+ "FileInfoModel": {
+ "description": "File info",
+ "type": "object",
+ "properties": {
+ "size": {
+ "format": "int64",
+ "description": "The size of the file in Bytes. When a file is\r\ncurrently opened for write, the size might not be\r\naccurate or available.",
+ "type": "integer"
+ },
+ "writable": {
+ "description": "Whether the file is writable.",
+ "type": "boolean"
+ },
+ "openCount": {
+ "format": "int32",
+ "description": "The number of currently valid file handles on\r\nthe file.",
+ "type": "integer"
+ },
+ "mimeType": {
+ "description": "The media type of the file based on RFC 2046.",
+ "type": "string"
+ },
+ "maxBufferSize": {
+ "format": "int64",
+ "description": "The maximum number of bytes of\r\nthe read and write buffers.",
+ "type": "integer"
+ },
+ "lastModified": {
+ "format": "date-time",
+ "description": "The time the file was last modified.",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FileInfoModelServiceResponse": {
+ "description": "Response envelope",
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/FileInfoModel"
+ },
+ "errorInfo": {
+ "$ref": "#/definitions/ServiceResultModel"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FileSystemObjectModel": {
+ "description": "File system object model",
+ "type": "object",
+ "properties": {
+ "nodeId": {
+ "description": "The node id of the filesystem object",
+ "type": "string"
+ },
+ "browsePath": {
+ "description": "The browse path to the filesystem object",
+ "type": "array",
+ "items": {
+ "type": "string"
+ }
+ },
+ "name": {
+ "description": "The name of the filesystem object",
+ "type": "string"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FileSystemObjectModelIEnumerableServiceResponse": {
+ "description": "Response envelope",
+ "type": "object",
+ "properties": {
+ "result": {
+ "description": "Result",
+ "type": "array",
+ "items": {
+ "$ref": "#/definitions/FileSystemObjectModel"
+ }
+ },
+ "errorInfo": {
+ "$ref": "#/definitions/ServiceResultModel"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FileSystemObjectModelRequestEnvelope": {
+ "description": "Wraps a request and a connection to bind to a\r\nbody more easily for api that requires a\r\nconnection endpoint",
+ "required": [
+ "connection"
+ ],
+ "type": "object",
+ "properties": {
+ "connection": {
+ "$ref": "#/definitions/ConnectionModel"
+ },
+ "request": {
+ "$ref": "#/definitions/FileSystemObjectModel"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FileSystemObjectModelServiceResponse": {
+ "description": "Response envelope",
+ "type": "object",
+ "properties": {
+ "result": {
+ "$ref": "#/definitions/FileSystemObjectModel"
+ },
+ "errorInfo": {
+ "$ref": "#/definitions/ServiceResultModel"
+ }
+ },
+ "additionalProperties": false
+ },
+ "FileSystemObjectModelServiceResponseIAsyncEnumerable": {
+ "type": "object",
+ "additionalProperties": false
+ },
"FilterOperandModel": {
"description": "Filter operand",
"type": "object",
@@ -9543,6 +10292,10 @@
"name": "Discovery",
"description": "\r\n\r\n OPC UA and network discovery related API.\r\n \r\n\r\n\r\n The method name for all transports other than HTTP (which uses the shown\r\n HTTP methods and resource uris) is the name of the subsection header.\r\n To use the version specific method append \"_V1\" or \"_V2\" to the method\r\n "
},
+ {
+ "name": "FileSystem",
+ "description": "\r\n\r\n This section lists the file transfer API provided by OPC Publisher providing\r\n access to file transfer services to move files in and out of a server\r\n using the File transfer specification.\r\n \r\n\r\n\r\n The method name for all transports other than HTTP (which uses the shown\r\n HTTP methods and resource uris) is the name of the subsection header.\r\n To use the version specific method append \"_V1\" or \"_V2\" to the method\r\n name.\r\n "
+ },
{
"name": "General",
"description": "\r\n\r\n This section lists the general APi provided by OPC Publisher providing\r\n all connection, endpoint and address space related API methods.\r\n \r\n\r\n\r\n The method name for all transports other than HTTP (which uses the shown\r\n HTTP methods and resource uris) is the name of the subsection header.\r\n To use the version specific method append \"_V1\" or \"_V2\" to the method\r\n name.\r\n "
diff --git a/docs/opc-publisher/readme.md b/docs/opc-publisher/readme.md
index f59236fc7d..e99e70705e 100644
--- a/docs/opc-publisher/readme.md
+++ b/docs/opc-publisher/readme.md
@@ -370,7 +370,7 @@ The simplest way to configure OPC Publisher is via a file. A basic configuration
]
```
-Example configuration files are [`publishednodes_2.5.json`](publishednodes_2.5.json?raw=1) and [`publishednodes_2.8.json`](publishednodes_2.8.json?raw=1).
+Example configuration files are [here](publishednodes_2.5.json?raw=1) and [here](publishednodes_2.8.json?raw=1).
### Configuration Schema
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 3cf474cb61..bae42c71ef 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,6 @@
enable
-
+
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileInfoModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileInfoModel.cs
new file mode 100644
index 0000000000..a1b5bb7c90
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileInfoModel.cs
@@ -0,0 +1,63 @@
+// ------------------------------------------------------------
+// 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;
+ using System.Runtime.Serialization;
+
+ ///
+ /// File info
+ ///
+ [DataContract]
+ public record FileInfoModel
+ {
+ ///
+ /// The size of the file in Bytes. When a file is
+ /// currently opened for write, the size might not be
+ /// accurate or available.
+ ///
+ [DataMember(Name = "size", Order = 0,
+ EmitDefaultValue = false)]
+ public long? Size { get; init; }
+
+ ///
+ /// Whether the file is writable.
+ ///
+ [DataMember(Name = "writable", Order = 1,
+ EmitDefaultValue = false)]
+ public bool Writable { get; init; }
+
+ ///
+ /// The number of currently valid file handles on
+ /// the file.
+ ///
+ [DataMember(Name = "openCount", Order = 2,
+ EmitDefaultValue = false)]
+ public ushort OpenCount { get; init; }
+
+ ///
+ /// The media type of the file based on RFC 2046.
+ ///
+ [DataMember(Name = "mimeType", Order = 3,
+ EmitDefaultValue = false)]
+ public string? MimeType { get; init; }
+
+ ///
+ /// The maximum number of bytes of
+ /// the read and write buffers.
+ ///
+ [DataMember(Name = "maxBufferSize", Order = 4,
+ EmitDefaultValue = false)]
+ public uint? MaxBufferSize { get; init; }
+
+ ///
+ /// The time the file was last modified.
+ ///
+ [DataMember(Name = "lastModified", Order = 5,
+ EmitDefaultValue = false)]
+ public DateTime? LastModified { get; init; }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileSystemObjectModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileSystemObjectModel.cs
new file mode 100644
index 0000000000..ba0815f0d2
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileSystemObjectModel.cs
@@ -0,0 +1,38 @@
+// ------------------------------------------------------------
+// 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.Collections.Generic;
+ using System.Runtime.Serialization;
+
+ ///
+ /// File system object model
+ ///
+ [DataContract]
+ public record FileSystemObjectModel
+ {
+ ///
+ /// The node id of the filesystem object
+ ///
+ [DataMember(Name = "nodeId", Order = 0,
+ EmitDefaultValue = false)]
+ public string? NodeId { get; init; }
+
+ ///
+ /// The browse path to the filesystem object
+ ///
+ [DataMember(Name = "browsePath", Order = 1,
+ EmitDefaultValue = false)]
+ public IReadOnlyList? BrowsePath { get; init; }
+
+ ///
+ /// The name of the filesystem object
+ ///
+ [DataMember(Name = "name", Order = 2,
+ EmitDefaultValue = false)]
+ public string? Name { get; init; }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileWriteMode.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileWriteMode.cs
new file mode 100644
index 0000000000..e097b2d99d
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/FileWriteMode.cs
@@ -0,0 +1,35 @@
+// ------------------------------------------------------------
+// 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;
+
+ ///
+ /// File write mode
+ ///
+ [DataContract]
+ public enum FileWriteMode
+ {
+ ///
+ /// The file is opened for writing.
+ ///
+ [EnumMember(Value = "Write")]
+ Write,
+
+ ///
+ /// The existing content of the file is erased.
+ ///
+ [EnumMember(Value = "Create")]
+ Create,
+
+ ///
+ /// The file is opened and positioned
+ /// at end of the file
+ ///
+ [EnumMember(Value = "Append")]
+ Append
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedDataSetSettingsModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedDataSetSettingsModel.cs
index a4b2f77778..e219e11901 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedDataSetSettingsModel.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedDataSetSettingsModel.cs
@@ -126,5 +126,19 @@ public sealed record class PublishedDataSetSettingsModel
[DataMember(Name = "defaultSamplingInterval", Order = 15,
EmitDefaultValue = false)]
public TimeSpan? DefaultSamplingInterval { get; set; }
+
+ ///
+ /// Default heartbeat interval
+ ///
+ [DataMember(Name = "DefaultHeartbeatInterval", Order = 16,
+ EmitDefaultValue = false)]
+ public TimeSpan? DefaultHeartbeatInterval { get; set; }
+
+ ///
+ /// The default behavior of heartbeat
+ ///
+ [DataMember(Name = "DefaultHeartbeatBehavior", Order = 17,
+ EmitDefaultValue = false)]
+ public HeartbeatBehavior? DefaultHeartbeatBehavior { get; set; }
}
}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs
index 59a2e8acee..7351995645 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/PublishedNodesEntryModel.cs
@@ -345,7 +345,7 @@ public sealed record class PublishedNodesEntryModel
/// Republish after transferring the subscription during
/// reconnect handling unless subscription transfer was disabled.
///
- [DataMember(Name = "republishAfterTransfer", Order = 41,
+ [DataMember(Name = "RepublishAfterTransfer", Order = 41,
EmitDefaultValue = false)]
public bool? RepublishAfterTransfer { get; set; }
@@ -432,11 +432,34 @@ public sealed record class PublishedNodesEntryModel
EmitDefaultValue = false)]
public bool? MessageRetention { get; set; }
+ ///
+ /// Default heartbeat interval in milliseconds
+ ///
+ [DataMember(Name = "DefaultHeartbeatInterval", Order = 54,
+ EmitDefaultValue = false)]
+ public int? DefaultHeartbeatInterval { get; set; }
+
+ ///
+ /// Default heartbeat interval for all nodes as duration. Takes
+ /// precedence over if
+ /// defined.
+ ///
+ [DataMember(Name = "DefaultHeartbeatIntervalTimespan", Order = 55,
+ EmitDefaultValue = false)]
+ public TimeSpan? DefaultHeartbeatIntervalTimespan { get; set; }
+
+ ///
+ /// Default heartbeat behavior for all nodes
+ ///
+ [DataMember(Name = "DefaultHeartbeatBehavior", Order = 56,
+ EmitDefaultValue = false)]
+ public HeartbeatBehavior? DefaultHeartbeatBehavior { get; set; }
+
///
/// Dump server diagnostics for the connection to enable
/// advanced troubleshooting scenarios.
///
- [DataMember(Name = "DumpConnectionDiagnostics", Order = 54,
+ [DataMember(Name = "DumpConnectionDiagnostics", Order = 98,
EmitDefaultValue = false)]
public bool? DumpConnectionDiagnostics { get; set; }
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/RequestEnvelope.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/RequestEnvelope.cs
index 0d8f75fdf6..e83e77a52e 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/RequestEnvelope.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/RequestEnvelope.cs
@@ -15,7 +15,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Models
///
///
[DataContract]
- public sealed record class RequestEnvelope
+ public record class RequestEnvelope
{
///
/// Connection the request is targeting
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/ServiceResponse.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ServiceResponse.cs
new file mode 100644
index 0000000000..2d9803d52c
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/ServiceResponse.cs
@@ -0,0 +1,31 @@
+// ------------------------------------------------------------
+// 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;
+
+ ///
+ /// Response envelope
+ ///
+ ///
+ [DataContract]
+ public sealed record class ServiceResponse where T : class
+ {
+ ///
+ /// Result
+ ///
+ [DataMember(Name = "result", Order = 0,
+ EmitDefaultValue = false)]
+ public T? Result { get; set; }
+
+ ///
+ /// Service result in case of error
+ ///
+ [DataMember(Name = "errorInfo", Order = 1,
+ EmitDefaultValue = false)]
+ public ServiceResultModel? ErrorInfo { get; set; }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Models/src/WriterGroupDiagnosticModel.cs b/src/Azure.IIoT.OpcUa.Publisher.Models/src/WriterGroupDiagnosticModel.cs
index 74c8e8436f..ed7a60d220 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Models/src/WriterGroupDiagnosticModel.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Models/src/WriterGroupDiagnosticModel.cs
@@ -455,5 +455,19 @@ public record class WriterGroupDiagnosticModel
[DataMember(Name = "MonitoredOpcNodesLateCount", Order = 67,
EmitDefaultValue = true)]
public long MonitoredOpcNodesLateCount { get; set; }
+
+ ///
+ /// Nodes with active heartbeat timer
+ ///
+ [DataMember(Name = "ActiveHeartbeatCount", Order = 68,
+ EmitDefaultValue = true)]
+ public long ActiveHeartbeatCount { get; set; }
+
+ ///
+ /// Nodes with active condition snapshot timer
+ ///
+ [DataMember(Name = "ActiveConditionCount", Order = 69,
+ EmitDefaultValue = true)]
+ public long ActiveConditionCount { get; set; }
}
}
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 2ebe03f00b..62cf15f97e 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 ae3c93d0d8..580adbf240 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
-
-
+
+
@@ -26,6 +26,7 @@
+
\ No newline at end of file
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Profiles/Heartbeat3.json b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Profiles/Heartbeat3.json
new file mode 100644
index 0000000000..fb2b068e62
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Profiles/Heartbeat3.json
@@ -0,0 +1,26 @@
+[
+ {
+ "EndpointUrl": "{{EndpointUrl}}",
+ "DefaultHeartbeatInterval": 60000,
+ "DataSetPublishingInterval": 1000,
+ "DataSetSamplingInterval": 1000,
+ "DataSetFetchDisplayNames": true,
+ "OpcNodes": [
+ {
+ "Id": "nsu=http://opcfoundation.org/UA/Plc/Applications;s=FastUIntScalar1"
+ },
+ {
+ "Id": "nsu=http://opcfoundation.org/UA/Plc/Applications;s=FastUIntScalar2"
+ },
+ {
+ "Id": "nsu=http://opcfoundation.org/UA/Plc/Applications;s=FastUIntScalar3"
+ },
+ {
+ "Id": "nsu=http://opcfoundation.org/UA/Plc/Applications;s=SlowUIntScalar2"
+ },
+ {
+ "Id": "nsu=http://opcfoundation.org/UA/Plc/Applications;s=SlowUIntScalar3"
+ }
+ ]
+ }
+]
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs
index e0f455255e..0fb7d8d2c0 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/cli/Program.cs
@@ -24,10 +24,10 @@ namespace Azure.IIoT.OpcUa.Publisher.Module.Runtime
using System.Collections.Generic;
using System.IO;
using System.Linq;
+ using System.Net;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
- using System.Net;
///
/// Publisher module host process
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 b1f9504484..d1698787ce 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,14 @@
-
-
-
-
-
-
+
+
+
+
+
+
-
+
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/FileSystemController.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/FileSystemController.cs
new file mode 100644
index 0000000000..ecbea10d9f
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Controllers/FileSystemController.cs
@@ -0,0 +1,519 @@
+// ------------------------------------------------------------
+// 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.Controllers
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Filters;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Asp.Versioning;
+ using Furly;
+ using Furly.Extensions.Serializers;
+ using Furly.Tunnel.Router;
+ using Microsoft.AspNetCore.Authorization;
+ using Microsoft.AspNetCore.Http;
+ using Microsoft.AspNetCore.Mvc;
+ using Microsoft.Extensions.Primitives;
+ using System;
+ using System.Collections.Generic;
+ using System.ComponentModel.DataAnnotations;
+ using System.Runtime.Serialization;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ ///
+ ///
+ /// This section lists the file transfer API provided by OPC Publisher providing
+ /// access to file transfer services to move files in and out of a server
+ /// using the File transfer specification.
+ ///
+ ///
+ /// The method name for all transports other than HTTP (which uses the shown
+ /// HTTP methods and resource uris) is the name of the subsection header.
+ /// To use the version specific method append "_V1" or "_V2" to the method
+ /// name.
+ ///
+ ///
+ [Version("_V1")]
+ [Version("_V2")]
+ [Version("")]
+ [RouterExceptionFilter]
+ [ControllerExceptionFilter]
+ [ApiVersion("2")]
+ [Route("v{version:apiVersion}/filesystem")]
+ [ApiController]
+ [Authorize]
+ [Produces(ContentMimeType.Json, ContentMimeType.MsgPack)]
+ [Consumes(ContentMimeType.Json, ContentMimeType.MsgPack)]
+ public class FileSystemController : ControllerBase, IMethodController
+ {
+ ///
+ /// Create controller with service
+ ///
+ ///
+ ///
+ public FileSystemController(IFileSystemServices files,
+ IJsonSerializer serializer)
+ {
+ _files = files ?? throw new ArgumentNullException(nameof(files));
+ _serializer = serializer ?? throw new ArgumentNullException(nameof(serializer));
+ }
+
+ ///
+ /// GetFileSystems
+ ///
+ ///
+ /// Gets all file systems of the server.
+ ///
+ /// The connection information identifying the
+ /// server to connect to perform the operation on.
+ ///
+ /// The directories.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("list")]
+ public IAsyncEnumerable> GetFileSystemsAsync(
+ [FromBody][Required] ConnectionModel connection,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(connection);
+ return _files.GetFileSystemsAsync(connection, ct);
+ }
+
+ ///
+ /// GetDirectories
+ ///
+ ///
+ /// Gets all directories in a directory or file system
+ ///
+ /// The directory or filesystem object and connection
+ /// information identifying the server to connect to perform the operation
+ /// on.
+ ///
+ /// The directories.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("list/directories")]
+ public async Task>> GetDirectoriesAsync(
+ [FromBody][Required] RequestEnvelope request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ return await _files.GetDirectoriesAsync(request.Connection,
+ request.Request, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// GetFiles
+ ///
+ ///
+ /// Get files in a directory or file system on a server.
+ ///
+ /// The directory or filesystem object and connection
+ /// information identifying the server to connect to perform the operation
+ /// on.
+ ///
+ /// The file information.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("list/files")]
+ public async Task>> GetFilesAsync(
+ [FromBody][Required] RequestEnvelope request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ return await _files.GetFilesAsync(request.Connection,
+ request.Request, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// GetParent
+ ///
+ ///
+ /// Gets the parent directory or filesystem of a file or directory.
+ ///
+ /// The file or directory object and connection information
+ /// identifying the server to connect to perform the operation on.
+ ///
+ /// The parent directory or filesystem.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("parent")]
+ public async Task> GetParentAsync(
+ [FromBody][Required] RequestEnvelope request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ return await _files.GetParentAsync(request.Connection,
+ request.Request, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// GetFileInfo
+ ///
+ ///
+ /// Gets the file information for a file on the server.
+ ///
+ /// The file object and connection information
+ /// identifying the server to connect to perform the operation on.
+ ///
+ /// The file information.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("info/file")]
+ public async Task> GetFileInfoAsync(
+ [FromBody][Required] RequestEnvelope request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ return await _files.GetFileInfoAsync(request.Connection,
+ request.Request, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// CreateFile
+ ///
+ ///
+ /// Create a new file in a directory or file system on the server
+ ///
+ /// The file system or directory object to create the
+ /// file in and the connection information identifying the server to
+ /// connect to perform the operation on.
+ /// The name of the file to create as child
+ /// under the directory or filesystem provided
+ ///
+ /// The new directory.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("create/file/{name}")]
+ public async Task> CreateFileAsync(
+ [FromBody][Required] RequestEnvelope request,
+ string name, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(name);
+ return await _files.CreateFileAsync(request.Connection,
+ request.Request, name, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// CreateDirectory
+ ///
+ ///
+ /// Create a new directory in an existing file system or directory on the server.
+ ///
+ /// The file system or directory object to create the
+ /// directory in and the connection information identifying the server to
+ /// connect to perform the operation on.
+ /// The name of the directory to create as child
+ /// under the parent directory provided
+ ///
+ /// The new file.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("create/directory/{name}")]
+ public async Task> CreateDirectoryAsync(
+ [FromBody][Required] RequestEnvelope request,
+ string name, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(name);
+ return await _files.CreateDirectoryAsync(request.Connection,
+ request.Request, name, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// DeleteFileSystemObject
+ ///
+ ///
+ /// Delete a file or directory in an existing file system on the server.
+ ///
+ /// The file or directory object to delete and the
+ /// connection information identifying the server to connect to perform
+ /// the operation on.
+ ///
+ /// The new file.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("delete")]
+ public async Task DeleteFileSystemObjectAsync(
+ [FromBody][Required] RequestEnvelope request,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ return await _files.DeleteFileSystemObjectAsync(request.Connection,
+ request.Request, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// DeleteFileOrDirectory
+ ///
+ ///
+ /// Delete a file or directory in the specified directory or file system.
+ ///
+ /// The filesystem or directory object in which to
+ /// delete the specified file or directory and the connection to use for
+ /// the operation.
+ /// The node id of the file or
+ /// directory to delete
+ ///
+ /// The new file.
+ ///
+ /// is null.
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("delete/{fileOrDirectoryNodeId}")]
+ public async Task DeleteFileOrDirectoryAsync(
+ [FromBody][Required] RequestEnvelope request,
+ string fileOrDirectoryNodeId, CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ ArgumentNullException.ThrowIfNull(request.Connection);
+ ArgumentNullException.ThrowIfNull(request.Request);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(fileOrDirectoryNodeId);
+ return await _files.DeleteFileSystemObjectAsync(request.Connection,
+ new FileSystemObjectModel
+ {
+ NodeId = fileOrDirectoryNodeId
+ }, request.Request, ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Download
+ ///
+ ///
+ /// Download a file from the server
+ ///
+ /// The connection information identifying the server
+ /// to connect to perform the operation on. This is passed as json serialized via
+ /// the header "x-ms-connection"
+ /// The file object to upload. This is passed as json
+ /// serialized via the header "x-ms-target"
+ ///
+ ///
+ ///
+ /// is null.
+ ///
+ /// is null.
+ /// The operation is not supported
+ /// over the transport chosen
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpGet("download")]
+ public async Task DownloadAsync(
+ [FromHeader(Name = "x-ms-connection")][Required] string connectionJson,
+ [FromHeader(Name = "x-ms-target")][Required] string fileObjectJson,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(connectionJson);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(fileObjectJson);
+
+ var connection = _serializer.Deserialize(connectionJson);
+ var fileObject = _serializer.Deserialize(fileObjectJson);
+
+ ArgumentNullException.ThrowIfNull(connection);
+ ArgumentNullException.ThrowIfNull(fileObject);
+
+ if (HttpContext == null)
+ {
+ throw new NotSupportedException("Download not supported");
+ }
+ var response = HttpContext.Response;
+ await response.StartAsync(ct).ConfigureAwait(false);
+ var result = await _files.CopyToAsync(connection,
+ fileObject, response.Body, ct).ConfigureAwait(false);
+ if (result?.StatusCode != 0)
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ HttpContext.Response.Headers.Append("errorInfo",
+ new StringValues(_serializer.SerializeObjectToString(result)));
+ }
+ await response.CompleteAsync().ConfigureAwait(false);
+ }
+
+ ///
+ /// Upload
+ ///
+ ///
+ /// Upload a file to the server.
+ ///
+ /// The connection information identifying the server
+ /// to connect to perform the operation on. This is passed as json serialized via
+ /// the header "x-ms-connection"
+ /// The file object to upload. This is passed as json
+ /// serialized via the header "x-ms-target"
+ /// The file write mode to use passed as header "x-ms-mode"
+ ///
+ ///
+ ///
+ ///
+ /// is null.
+ ///
+ /// is null.
+ ///
+ /// is null.
+ /// The operation is not supported
+ /// over the transport chosen
+ /// The operation was successful or the response payload
+ /// contains relevant error information.
+ /// The passed in information is invalid
+ /// The operation timed out.
+ /// An unexpected error occurred
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status408RequestTimeout)]
+ [ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status500InternalServerError)]
+ [HttpPost("upload")]
+ public async Task UploadAsync(
+ [FromHeader(Name = "x-ms-connection")][Required] string connectionJson,
+ [FromHeader(Name = "x-ms-target")][Required] string fileObjectJson,
+ [FromHeader(Name = "x-ms-mode")][Required] string modeJson,
+ CancellationToken ct = default)
+ {
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(connectionJson);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(fileObjectJson);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(modeJson);
+
+ var connection = _serializer.Deserialize(connectionJson);
+ var fileObject = _serializer.Deserialize(fileObjectJson);
+ var mode = _serializer.Deserialize(modeJson);
+
+ ArgumentNullException.ThrowIfNull(connection);
+ ArgumentNullException.ThrowIfNull(fileObject);
+
+ if (HttpContext == null)
+ {
+ throw new NotSupportedException("Upload not supported");
+ }
+
+ await using (var _ = HttpContext.Request.Body.ConfigureAwait(false))
+ {
+ var result = await _files.CopyFromAsync(connection,
+ fileObject, HttpContext.Request.Body, mode, ct).ConfigureAwait(false);
+
+ if (result?.StatusCode != 0)
+ {
+ HttpContext.Response.StatusCode = StatusCodes.Status500InternalServerError;
+ HttpContext.Response.Headers.Append("errorInfo",
+ new StringValues(_serializer.SerializeObjectToString(result)));
+ }
+ }
+ }
+ private readonly IFileSystemServices _files;
+ private readonly IJsonSerializer _serializer;
+ }
+
+ ///
+ /// Combines a request envelope and file
+ ///
+ ///
+ public record RequestEnvelopeWithFile : RequestEnvelope
+ {
+ ///
+ /// File to upload
+ ///
+ [DataMember(Name = "file", Order = 2)]
+ public IFormFile? File { get; set; }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs
index 970aaa29f7..dd201f6de1 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/src/Runtime/CommandLine.cs
@@ -441,7 +441,7 @@ public CommandLine(string[] args, CommandLineLogger? logger = null)
"Complex types (structures, enumerations) a server exposes are preloaded from the server after the session is connected. In some cases this can cause problems either on the client or server itself. Use this setting to disable pre-loading support.\nNote that since the complex type system is used for meta data messages it will still be loaded at the time the subscription is created, therefore also disable meta data support if you want to ensure the complex types are never loaded for an endpoint.\nDefault: `false`.\n",
(bool? b) => this[OpcUaClientConfig.DisableComplexTypePreloadingKey] = b?.ToString() ?? "True" },
{ $"peh|activepublisherrorhandling:|{OpcUaClientConfig.ActivePublishErrorHandlingKey}:",
- $"Actively handle reconnecting a session when publishing errors occur due to issues in the underlying connectivity rather than letting the stack and keep alive handling manage reconnecting.\nNote that the default will be `false` in future releases.\nDefault: `{OpcUaClientConfig.ActivePublishErrorHandlingDefault}`.\n",
+ $"Actively handle reconnecting a session when publishing errors occur due to issues in the underlying connectivity instead of letting the stack and keep alive handling manage reconnecting.\nNote that the default was `true` in previous releases. If you unexpectedly encounter issues with non-publishing subscriptions, enable this option.\nDefault: `{OpcUaClientConfig.ActivePublishErrorHandlingDefault}`.\n",
(bool? b) => this[OpcUaClientConfig.ActivePublishErrorHandlingKey] = b?.ToString() ?? "True" },
{ $"otl|opctokenlifetime=|{OpcUaClientConfig.SecurityTokenLifetimeKey}=",
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 3f37b0e32c..11cc1ad12b 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
@@ -8,7 +8,7 @@
-
+
all
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/FileSystemServicesRestClient.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/FileSystemServicesRestClient.cs
new file mode 100644
index 0000000000..1f1d96be9a
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Clients/FileSystemServicesRestClient.cs
@@ -0,0 +1,489 @@
+// ------------------------------------------------------------
+// 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.Buffers;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.IO.Pipelines;
+ using System.Linq;
+ using System.Net.Http;
+ using System.Net.Http.Headers;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ ///
+ /// Implementation of file system services over http
+ ///
+ public sealed class FileSystemServicesRestClient : IFileSystemServices
+ {
+ ///
+ /// Create service client
+ ///
+ ///
+ ///
+ ///
+ public FileSystemServicesRestClient(IHttpClientFactory httpClient,
+ IOptions options, ISerializer serializer) :
+ this(httpClient, options?.Value.Target, serializer)
+ {
+ }
+
+ ///
+ /// Create service client
+ ///
+ ///
+ ///
+ ///
+ public FileSystemServicesRestClient(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> GetFileSystemsAsync(
+ ConnectionModel endpoint, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/list");
+ return _httpClient.PostStreamAsync>(uri,
+ endpoint, _serializer, ct: ct);
+ }
+
+ ///
+ public async Task>> GetDirectoriesAsync(
+ ConnectionModel endpoint, FileSystemObjectModel fileSystemOrDirectory, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(fileSystemOrDirectory);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/list/directories");
+ return await _httpClient.PostAsync>>(uri,
+ RequestBody(endpoint, fileSystemOrDirectory), _serializer, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task>> GetFilesAsync(
+ ConnectionModel endpoint, FileSystemObjectModel fileSystemOrDirectory, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(fileSystemOrDirectory);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/list/files");
+ return await _httpClient.PostAsync>>(uri,
+ RequestBody(endpoint, fileSystemOrDirectory), _serializer, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> GetParentAsync(ConnectionModel endpoint,
+ FileSystemObjectModel fileOrDirectoryObject, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(fileOrDirectoryObject);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/parent");
+ return await _httpClient.PostAsync>(uri,
+ RequestBody(endpoint, fileOrDirectoryObject), _serializer, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> GetFileInfoAsync(ConnectionModel endpoint,
+ FileSystemObjectModel file, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(file);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/info/file");
+ return await _httpClient.PostAsync>(uri,
+ RequestBody(endpoint, file), _serializer, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> OpenReadAsync(ConnectionModel endpoint,
+ FileSystemObjectModel file, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(file);
+ return await DownloadStream.CreateAsync(this, endpoint, file, ct).ConfigureAwait(false);
+ }
+
+ ///
+ public Task> OpenWriteAsync(ConnectionModel endpoint,
+ FileSystemObjectModel file, FileWriteMode mode, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(file);
+
+ return Task.FromResult(UploadStream.Create(this, endpoint, file, mode, ct));
+ }
+
+ ///
+ public async Task> CreateDirectoryAsync(ConnectionModel endpoint,
+ FileSystemObjectModel fileSystemOrDirectory, string name, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(fileSystemOrDirectory);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(name);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/create/directory/{name.UrlEncode()}");
+ return await _httpClient.PostAsync>(uri,
+ RequestBody(endpoint, fileSystemOrDirectory), _serializer, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task> CreateFileAsync(ConnectionModel endpoint,
+ FileSystemObjectModel fileSystemOrDirectory, string name, CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(fileSystemOrDirectory);
+ ArgumentNullException.ThrowIfNullOrWhiteSpace(name);
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/create/file/{name.UrlEncode()}");
+ return await _httpClient.PostAsync>(uri,
+ RequestBody(endpoint, fileSystemOrDirectory), _serializer, ct: ct).ConfigureAwait(false);
+ }
+
+ ///
+ public async Task DeleteFileSystemObjectAsync(ConnectionModel endpoint,
+ FileSystemObjectModel fileOrDirectoryObject, FileSystemObjectModel parentFileSystemOrDirectory,
+ CancellationToken ct)
+ {
+ ArgumentNullException.ThrowIfNull(endpoint);
+ ArgumentNullException.ThrowIfNull(fileOrDirectoryObject);
+ if (parentFileSystemOrDirectory == null)
+ {
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/delete");
+ return await _httpClient.PostAsync(uri,
+ RequestBody(endpoint, fileOrDirectoryObject), _serializer, ct: ct).ConfigureAwait(false);
+ }
+ else
+ {
+ if (fileOrDirectoryObject.BrowsePath?.Count > 0)
+ {
+ throw new NotSupportedException("Not yet supported");
+ }
+ var uri = new Uri($"{_serviceUri}/v2/filesystem/delete/{fileOrDirectoryObject.NodeId.UrlEncode()}");
+ return await _httpClient.PostAsync(uri,
+ RequestBody(endpoint, parentFileSystemOrDirectory), _serializer, ct: ct).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Create envelope
+ ///
+ ///
+ ///
+ ///
+ ///
+ private static RequestEnvelope RequestBody(ConnectionModel connection, T request)
+ {
+ return new RequestEnvelope { Connection = connection, Request = request };
+ }
+
+ ///
+ /// File stream wraps a body of a response
+ ///
+ internal sealed class DownloadStream : Stream
+ {
+ ///
+ public override bool CanRead => _body.CanRead;
+ ///
+ public override bool CanSeek => _body.CanSeek;
+ ///
+ public override bool CanWrite => _body.CanWrite;
+ ///
+ public override long Length => _body.Length;
+ ///
+ public override long Position
+ {
+ get => _body.Position;
+ set => _body.Position = value;
+ }
+
+ ///
+ /// Create download stream
+ ///
+ ///
+ ///
+ ///
+ private DownloadStream(HttpClient httpClient, HttpRequestMessage request, Stream body)
+ {
+ _httpClient = httpClient;
+ _request = request;
+ _body = body;
+ }
+
+ ///
+ public static async Task> CreateAsync(FileSystemServicesRestClient outer,
+ ConnectionModel endpoint, FileSystemObjectModel file, CancellationToken ct)
+ {
+ var uri = new Uri($"{outer._serviceUri}/v2/filesystem/download");
+ var httpClient = outer._httpClient.CreateClient();
+ httpClient.Timeout = TimeSpan.FromMinutes(2);
+
+ var _serializer = new NewtonsoftJsonSerializer();
+ using var request = new HttpRequestMessage(HttpMethod.Get, uri);
+ request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/octet-stream"));
+ request.Headers.Add("x-ms-target", _serializer.SerializeObjectToString(file));
+ request.Headers.Add("x-ms-connection", _serializer.SerializeObjectToString(endpoint));
+ try
+ {
+ var response = await httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead,
+ ct).ConfigureAwait(false);
+ var stream = await response.Content.ReadAsStreamAsync(ct);
+ if (!response.IsSuccessStatusCode)
+ {
+ string errorInfo = null;
+ if (response.Headers.TryGetValues("errorInfo", out var header))
+ {
+ errorInfo = header.FirstOrDefault();
+ }
+ response.Dispose();
+ if (errorInfo != null)
+ {
+ // Error response
+ return new ServiceResponse
+ {
+ ErrorInfo = _serializer.Deserialize(errorInfo)
+ };
+ }
+ throw new HttpRequestException($"Failed to download file: {response.StatusCode}");
+ }
+ var client = httpClient;
+ httpClient = null;
+ return new ServiceResponse
+ {
+ Result = new DownloadStream(client, request, stream)
+ };
+ }
+ finally
+ {
+ httpClient?.Dispose();
+ }
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _body.Dispose();
+ _request.Dispose();
+ _httpClient.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ ///
+ public override void Flush()
+ {
+ _body.Flush();
+ }
+
+ ///
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ return _body.Read(buffer, offset, count);
+ }
+
+ ///
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ return _body.Seek(offset, origin);
+ }
+
+ ///
+ public override void SetLength(long value)
+ {
+ _body.SetLength(value);
+ }
+
+ ///
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ _body.Write(buffer, offset, count);
+ }
+
+ private readonly HttpClient _httpClient;
+ private readonly HttpRequestMessage _request;
+ private readonly Stream _body;
+ }
+
+ ///
+ /// Write stream wraps a request content body
+ ///
+ internal sealed class UploadStream : Stream
+ {
+ ///
+ public override bool CanRead => false;
+ ///
+ public override bool CanSeek => false;
+ ///
+ public override bool CanWrite => true;
+ ///
+ public override long Length { get => _length; }
+ ///
+ public override long Position { get; set; }
+
+ ///
+ /// Service response
+ ///
+ public ServiceResponse Result { get; private set; }
+
+ ///
+ /// Create upload stream
+ ///
+ ///
+ ///
+ ///
+ ///
+ public UploadStream(HttpClient httpClient,
+ HttpRequestMessage request, IJsonSerializer serializer, CancellationToken ct)
+ {
+ _httpClient = httpClient;
+ _request = request;
+ _serializer = serializer;
+ _request.Content = new StreamContent(_pipe.Reader.AsStream(false));
+ _streaming = StartAsync(ct);
+ }
+
+ ///
+ public static ServiceResponse Create(FileSystemServicesRestClient outer,
+ ConnectionModel endpoint, FileSystemObjectModel file, FileWriteMode mode, CancellationToken ct)
+ {
+ var uri = new Uri($"{outer._serviceUri}/v2/filesystem/upload");
+ var httpClient = outer._httpClient.CreateClient();
+ httpClient.Timeout = TimeSpan.FromMinutes(2);
+ var request = new HttpRequestMessage(HttpMethod.Post, uri);
+
+ var serializer = new NewtonsoftJsonSerializer();
+ request.Headers.Add("x-ms-target", serializer.SerializeObjectToString(file));
+ request.Headers.Add("x-ms-connection", serializer.SerializeObjectToString(endpoint));
+ request.Headers.Add("x-ms-mode", serializer.SerializeObjectToString(mode));
+
+ var stream = new UploadStream(httpClient, request, serializer, ct);
+ return new ServiceResponse
+ {
+ Result = stream
+ };
+ }
+
+ ///
+ public override void Flush()
+ {
+ }
+
+ ///
+ public override long Seek(long offset, SeekOrigin origin)
+ {
+ throw new NotSupportedException();
+ }
+
+ ///
+ public override void SetLength(long value)
+ {
+ throw new NotSupportedException();
+ }
+
+ ///
+ public override int Read(byte[] buffer, int offset, int count)
+ {
+ throw new NotSupportedException();
+ }
+
+ ///
+ public override void Write(byte[] buffer, int offset, int count)
+ {
+ _pipe.Writer.Write(buffer.AsSpan().Slice(offset, count));
+ _length += count;
+ }
+
+ ///
+ public override async ValueTask DisposeAsync()
+ {
+ // Stream owner is done writing and disposes
+ if (_streaming != null)
+ {
+ try
+ {
+ await _pipe.Writer.CompleteAsync().ConfigureAwait(false);
+
+ // now wait until fully sent and result received
+ await _streaming.ConfigureAwait(false);
+ _streaming = null;
+ }
+ catch (OperationCanceledException) { } // Ct triggered
+ finally
+ {
+ _request.Dispose();
+ _httpClient.Dispose();
+ }
+ }
+ await base.DisposeAsync();
+ }
+
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing && _streaming != null)
+ {
+ DisposeAsync().AsTask().GetAwaiter().GetResult();
+ }
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Start streaming
+ ///
+ ///
+ ///
+ ///
+ private async Task StartAsync(CancellationToken ct)
+ {
+ var response = await _httpClient.SendAsync(_request, ct).ConfigureAwait(false);
+
+ // Stream fully sent and response returned
+ if (!response.IsSuccessStatusCode)
+ {
+ string errorInfo = null;
+ if (response.Headers.TryGetValues("errorInfo", out var header))
+ {
+ errorInfo = header.FirstOrDefault();
+ }
+ response.Dispose();
+ if (errorInfo != null)
+ {
+ // Error response
+ this.Result = new ServiceResponse
+ {
+ ErrorInfo = _serializer.Deserialize(errorInfo)
+ };
+ }
+ throw new HttpRequestException($"Failed to upload file: {response.StatusCode}");
+ }
+ }
+
+ private readonly HttpClient _httpClient;
+ private readonly HttpRequestMessage _request;
+ private readonly Pipe _pipe = new();
+ private readonly IJsonSerializer _serializer;
+ private int _length;
+ private Task _streaming;
+ }
+
+ private readonly IHttpClientFactory _httpClient;
+ private readonly ISerializer _serializer;
+ private readonly string _serviceUri;
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/BrowseTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/BrowseTests.cs
new file mode 100644
index 0000000000..7fdb07a50b
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/BrowseTests.cs
@@ -0,0 +1,120 @@
+// ------------------------------------------------------------
+// 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.FileSystem.Json
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class BrowseTests : IClassFixture, IDisposable
+ {
+ public BrowseTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.Json);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private BrowseTests GetTests()
+ {
+ return new BrowseTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task GetFileSystemsTest1Async()
+ {
+ return GetTests().GetFileSystemsTest1Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest1Async()
+ {
+ return GetTests().GetDirectoriesTest1Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest2Async()
+ {
+ return GetTests().GetDirectoriesTest2Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest3Async()
+ {
+ return GetTests().GetDirectoriesTest3Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest4Async()
+ {
+ return GetTests().GetDirectoriesTest4Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest5Async()
+ {
+ return GetTests().GetDirectoriesTest5Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest6Async()
+ {
+ return GetTests().GetDirectoriesTest6Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest1Async()
+ {
+ return GetTests().GetFilesTest1Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest2Async()
+ {
+ return GetTests().GetFilesTest2Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest3Async()
+ {
+ return GetTests().GetFilesTest3Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest4Async()
+ {
+ return GetTests().GetFilesTest4Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest5Async()
+ {
+ return GetTests().GetFilesTest5Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest6Async()
+ {
+ return GetTests().GetFilesTest6Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/FileCollection.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/FileCollection.cs
new file mode 100644
index 0000000000..445f875474
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/FileCollection.cs
@@ -0,0 +1,16 @@
+// ------------------------------------------------------------
+// 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.FileSystem.Json
+{
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Xunit;
+
+ [CollectionDefinition(Name)]
+ public class FileCollection : ICollectionFixture
+ {
+ public const string Name = "FileSystemRestJson";
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/OperationsTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/OperationsTests.cs
new file mode 100644
index 0000000000..0019b797b4
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/OperationsTests.cs
@@ -0,0 +1,158 @@
+// ------------------------------------------------------------
+// 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.FileSystem.Json
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class OperationsTests : IClassFixture, IDisposable
+ {
+ public OperationsTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.Json);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private OperationsTests GetTests()
+ {
+ return new OperationsTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task CreateDirectoryTest1Async()
+ {
+ return GetTests().CreateDirectoryTest1Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest2Async()
+ {
+ return GetTests().CreateDirectoryTest2Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest3Async()
+ {
+ return GetTests().CreateDirectoryTest3Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest4Async()
+ {
+ return GetTests().CreateDirectoryTest4Async();
+ }
+
+ [SkippableFact]
+ public Task DeleteDirectoryTest1Async()
+ {
+ Skip.If(true, "TODO");
+ return GetTests().DeleteDirectoryTest1Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest2Async()
+ {
+ return GetTests().DeleteDirectoryTest2Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest3Async()
+ {
+ return GetTests().DeleteDirectoryTest3Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest1Async()
+ {
+ return GetTests().CreateFileTest1Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest2Async()
+ {
+ return GetTests().CreateFileTest2Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest3Async()
+ {
+ return GetTests().CreateFileTest3Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest4Async()
+ {
+ return GetTests().CreateFileTest4Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest1Async()
+ {
+ return GetTests().GetFileInfoTest1Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest2Async()
+ {
+ return GetTests().GetFileInfoTest2Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest3Async()
+ {
+ return GetTests().GetFileInfoTest3Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest1Async()
+ {
+ return GetTests().DeleteFileTest1Async();
+ }
+
+ [SkippableFact]
+ public Task DeleteFileTest2Async()
+ {
+ Skip.If(true, "TODO");
+ return GetTests().DeleteFileTest2Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest3Async()
+ {
+ return GetTests().DeleteFileTest3Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest4Async()
+ {
+ return GetTests().DeleteFileTest4Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest5Async()
+ {
+ return GetTests().DeleteFileTest5Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/ReadTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/ReadTests.cs
new file mode 100644
index 0000000000..24b1d2e5df
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/ReadTests.cs
@@ -0,0 +1,76 @@
+// ------------------------------------------------------------
+// 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.FileSystem.Json
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class ReadTests : IClassFixture, IDisposable
+ {
+ public ReadTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.Json);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private ReadTests GetTests()
+ {
+ return new ReadTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task ReadFileTest0Async()
+ {
+ return GetTests().ReadFileTest0Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest1Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest1Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest2Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest2Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest3Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest3Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest4Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest4Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/WriteTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/WriteTests.cs
new file mode 100644
index 0000000000..315756202a
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/Json/WriteTests.cs
@@ -0,0 +1,81 @@
+// ------------------------------------------------------------
+// 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.FileSystem.Json
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class WriteTests : IClassFixture, IDisposable
+ {
+ public WriteTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.Json);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private WriteTests GetTests()
+ {
+ return new WriteTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task WriteFileTest0Async()
+ {
+ return GetTests().WriteFileTest0Async();
+ }
+
+ [SkippableFact]
+ public Task WriteFileTest1Async()
+ {
+ Skip.If(true);
+ return GetTests().WriteFileTest1Async();
+ }
+
+ [SkippableFact]
+ public Task WriteFileTest2Async()
+ {
+ Skip.If(true);
+ return GetTests().WriteFileTest2Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest0Async()
+ {
+ return GetTests().AppendFileTest0Async();
+ }
+
+ [SkippableFact]
+ public Task AppendFileTest1Async()
+ {
+ Skip.If(true);
+ return GetTests().AppendFileTest1Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest2Async()
+ {
+ return GetTests().AppendFileTest2Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/BrowseTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/BrowseTests.cs
new file mode 100644
index 0000000000..5363aa5ade
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/BrowseTests.cs
@@ -0,0 +1,120 @@
+// ------------------------------------------------------------
+// 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.FileSystem.MsgPack
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class BrowseTests : IClassFixture, IDisposable
+ {
+ public BrowseTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.MsgPack);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private BrowseTests GetTests()
+ {
+ return new BrowseTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task GetFileSystemsTest1Async()
+ {
+ return GetTests().GetFileSystemsTest1Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest1Async()
+ {
+ return GetTests().GetDirectoriesTest1Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest2Async()
+ {
+ return GetTests().GetDirectoriesTest2Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest3Async()
+ {
+ return GetTests().GetDirectoriesTest3Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest4Async()
+ {
+ return GetTests().GetDirectoriesTest4Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest5Async()
+ {
+ return GetTests().GetDirectoriesTest5Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest6Async()
+ {
+ return GetTests().GetDirectoriesTest6Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest1Async()
+ {
+ return GetTests().GetFilesTest1Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest2Async()
+ {
+ return GetTests().GetFilesTest2Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest3Async()
+ {
+ return GetTests().GetFilesTest3Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest4Async()
+ {
+ return GetTests().GetFilesTest4Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest5Async()
+ {
+ return GetTests().GetFilesTest5Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest6Async()
+ {
+ return GetTests().GetFilesTest6Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/FileCollection.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/FileCollection.cs
new file mode 100644
index 0000000000..a9866b7518
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/FileCollection.cs
@@ -0,0 +1,16 @@
+// ------------------------------------------------------------
+// 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.FileSystem.MsgPack
+{
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Xunit;
+
+ [CollectionDefinition(Name)]
+ public class FileCollection : ICollectionFixture
+ {
+ public const string Name = "FileSystemRestMsgPack";
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/OperationsTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/OperationsTests.cs
new file mode 100644
index 0000000000..d0503508cd
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/OperationsTests.cs
@@ -0,0 +1,158 @@
+// ------------------------------------------------------------
+// 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.FileSystem.MsgPack
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class OperationsTests : IClassFixture, IDisposable
+ {
+ public OperationsTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.MsgPack);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private OperationsTests GetTests()
+ {
+ return new OperationsTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task CreateDirectoryTest1Async()
+ {
+ return GetTests().CreateDirectoryTest1Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest2Async()
+ {
+ return GetTests().CreateDirectoryTest2Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest3Async()
+ {
+ return GetTests().CreateDirectoryTest3Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest4Async()
+ {
+ return GetTests().CreateDirectoryTest4Async();
+ }
+
+ [SkippableFact]
+ public Task DeleteDirectoryTest1Async()
+ {
+ Skip.If(true, "TODO");
+ return GetTests().DeleteDirectoryTest1Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest2Async()
+ {
+ return GetTests().DeleteDirectoryTest2Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest3Async()
+ {
+ return GetTests().DeleteDirectoryTest3Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest1Async()
+ {
+ return GetTests().CreateFileTest1Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest2Async()
+ {
+ return GetTests().CreateFileTest2Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest3Async()
+ {
+ return GetTests().CreateFileTest3Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest4Async()
+ {
+ return GetTests().CreateFileTest4Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest1Async()
+ {
+ return GetTests().GetFileInfoTest1Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest2Async()
+ {
+ return GetTests().GetFileInfoTest2Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest3Async()
+ {
+ return GetTests().GetFileInfoTest3Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest1Async()
+ {
+ return GetTests().DeleteFileTest1Async();
+ }
+
+ [SkippableFact]
+ public Task DeleteFileTest2Async()
+ {
+ Skip.If(true, "TODO");
+ return GetTests().DeleteFileTest2Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest3Async()
+ {
+ return GetTests().DeleteFileTest3Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest4Async()
+ {
+ return GetTests().DeleteFileTest4Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest5Async()
+ {
+ return GetTests().DeleteFileTest5Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/ReadTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/ReadTests.cs
new file mode 100644
index 0000000000..578ac3b0d4
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/ReadTests.cs
@@ -0,0 +1,76 @@
+// ------------------------------------------------------------
+// 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.FileSystem.MsgPack
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class ReadTests : IClassFixture, IDisposable
+ {
+ public ReadTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.MsgPack);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private ReadTests GetTests()
+ {
+ return new ReadTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task ReadFileTest0Async()
+ {
+ return GetTests().ReadFileTest0Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest1Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest1Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest2Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest2Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest3Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest3Async();
+ }
+
+ [SkippableFact]
+ public Task ReadFileTest4Async()
+ {
+ Skip.If(true);
+ return GetTests().ReadFileTest4Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/WriteTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/WriteTests.cs
new file mode 100644
index 0000000000..0ac1ddfb92
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Controller/FileSystem/MsgPack/WriteTests.cs
@@ -0,0 +1,81 @@
+// ------------------------------------------------------------
+// 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.FileSystem.MsgPack
+{
+ using Azure.IIoT.OpcUa.Publisher.Module.Tests.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Azure.IIoT.OpcUa.Publisher.Testing.Tests;
+ using Autofac;
+ using System;
+ using System.Threading.Tasks;
+ using Xunit;
+ using Xunit.Abstractions;
+
+ [Collection(FileCollection.Name)]
+ public sealed class WriteTests : IClassFixture, IDisposable
+ {
+ public WriteTests(FileSystemServer server, PublisherModuleFixture module, ITestOutputHelper output)
+ {
+ _server = server;
+ _client = module.CreateRestClientContainer(output, TestSerializerType.MsgPack);
+ }
+
+ public void Dispose()
+ {
+ _client.Dispose();
+ }
+
+ private WriteTests GetTests()
+ {
+ return new WriteTests(
+ _client.Resolve>,
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly IContainer _client;
+
+ [Fact]
+ public Task WriteFileTest0Async()
+ {
+ return GetTests().WriteFileTest0Async();
+ }
+
+ [SkippableFact]
+ public Task WriteFileTest1Async()
+ {
+ Skip.If(true);
+ return GetTests().WriteFileTest1Async();
+ }
+
+ [SkippableFact]
+ public Task WriteFileTest2Async()
+ {
+ Skip.If(true);
+ return GetTests().WriteFileTest2Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest0Async()
+ {
+ return GetTests().AppendFileTest0Async();
+ }
+
+ [SkippableFact]
+ public Task AppendFileTest1Async()
+ {
+ Skip.If(true);
+ return GetTests().AppendFileTest1Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest2Async()
+ {
+ return GetTests().AppendFileTest2Async();
+ }
+ }
+}
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 2b0785105a..0e83f69237 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModule.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModule.cs
@@ -373,6 +373,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 e80409ab6a..fff1d67dd7 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModuleFixture.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Module/tests/Fixtures/PublisherModuleFixture.cs
@@ -7,6 +7,7 @@ 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.Sdk/src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj b/src/Azure.IIoT.OpcUa.Publisher.Sdk/src/Azure.IIoT.OpcUa.Publisher.Sdk.csproj
index 51f7b95cbb..924a23bc95 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 9602211562..ae8abf43c7 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,7 +15,7 @@
-
+
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 757cc71ba3..a2c21b365e 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
@@ -12,10 +12,10 @@
-
-
-
-
+
+
+
+
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 f006b61b8a..f6d124d2f4 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
@@ -28,9 +28,9 @@
-
-
-
+
+
+
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 a03195451b..3a2916ab89 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
@@ -8,7 +8,7 @@
-
+
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Clients/RegistryWebApiAdapter.cs b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Clients/RegistryWebApiAdapter.cs
index fe52771679..f8ba11950e 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Clients/RegistryWebApiAdapter.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Clients/RegistryWebApiAdapter.cs
@@ -11,7 +11,6 @@ namespace Azure.IIoT.OpcUa.Publisher.Service.WebApi.Tests.Clients
using System;
using System.Threading;
using System.Threading.Tasks;
- using Google.Api;
///
/// Registry services adapter to run dependent services outside of cloud.
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Extensions/EndpointManagerEx.cs b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Extensions/EndpointManagerEx.cs
index c74da6ad50..da86b06e42 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Extensions/EndpointManagerEx.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Service.WebApi/tests/Extensions/EndpointManagerEx.cs
@@ -20,6 +20,7 @@ internal static class EndpointManagerEx
///
///
///
+ ///
///
///
public static Task RegisterEndpointAsync(this IEndpointManager manager,
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 2c98b1a6dd..32bda18182 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/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj
index ab0a48e118..852fa3ad13 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj
+++ b/src/Azure.IIoT.OpcUa.Publisher.Service/tests/Azure.IIoT.OpcUa.Publisher.Service.Tests.csproj
@@ -11,7 +11,7 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
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 5fc279c8fa..5d3dac29e1 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/DirectoryBrowser.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryBrowser.cs
new file mode 100644
index 0000000000..9cdb69f6c8
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryBrowser.cs
@@ -0,0 +1,198 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+
+ ///
+ /// Browses the file system folder and files
+ ///
+ public class DirectoryBrowser : NodeBrowser
+ {
+ ///
+ /// Create browser
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public DirectoryBrowser(ISystemContext context, ViewDescription view,
+ NodeId referenceType, bool includeSubtypes, BrowseDirection browseDirection,
+ QualifiedName browseName, IEnumerable additionalReferences,
+ bool internalOnly, DirectoryObjectState source)
+ : base(context, view, referenceType, includeSubtypes, browseDirection,
+ browseName, additionalReferences, internalOnly)
+ {
+ _source = source;
+ _stage = Stage.Begin;
+ }
+
+ ///
+ /// Returns the next reference.
+ ///
+ /// The next reference that meets the browse criteria.
+ public override IReference Next()
+ {
+ lock (DataLock)
+ {
+ // enumerate pre-defined references.
+ // always call first to ensure any pushed-back references are returned first.
+ var reference = base.Next();
+
+ if (reference != null)
+ {
+ return reference;
+ }
+
+ // don't start browsing huge number of references when only internal references are requested.
+ if (InternalOnly)
+ {
+ return null;
+ }
+
+ if (!IsRequired(ReferenceTypeIds.HasComponent, false))
+ {
+ return null;
+ }
+
+ if (_stage == Stage.Begin)
+ {
+ _directories = System.IO.Directory.GetDirectories(_source.FullPath).ToList();
+ _stage = Stage.Directories;
+ }
+
+ // enumerate segments.
+ if (_stage == Stage.Directories)
+ {
+ reference = NextChild();
+
+ if (reference != null)
+ {
+ return reference;
+ }
+
+ _files = System.IO.Directory.GetFiles(_source.FullPath).ToList();
+ _stage = Stage.Files;
+ }
+
+ // enumerate files.
+ if (_stage == Stage.Files)
+ {
+ reference = NextChild();
+
+ if (reference != null)
+ {
+ return reference;
+ }
+
+ _stage = Stage.Done;
+ }
+
+ // all done.
+ return null;
+ }
+ }
+
+ ///
+ /// Returns the next child.
+ ///
+ private NodeStateReference NextChild()
+ {
+ NodeId targetId = null;
+
+ // check if a specific browse name is requested.
+ if (!QualifiedName.IsNull(BrowseName))
+ {
+ // browse name must be qualified by the correct namespace.
+ if (_source.BrowseName.NamespaceIndex != BrowseName.NamespaceIndex)
+ {
+ return null;
+ }
+
+ // look for matching directory.
+ if (_stage == Stage.Directories && _directories != null)
+ {
+ foreach (var name in _directories)
+ {
+ if (BrowseName.Name == Path.GetFileName(name))
+ {
+ targetId = ModelUtils.ConstructIdForDirectory(name, _source.NodeId.NamespaceIndex);
+ _directories = null;
+ break;
+ }
+ }
+ _directories = null;
+ }
+
+ // look for matching file.
+ if (_stage == Stage.Files && _files != null)
+ {
+ foreach (var name in _files)
+ {
+ if (BrowseName.Name == Path.GetFileName(name))
+ {
+ targetId = ModelUtils.ConstructIdForFile(name, _source.NodeId.NamespaceIndex);
+ _files = null;
+ break;
+ }
+ }
+ _files = null;
+ }
+ }
+ // return the child at the next position.
+ else
+ {
+ // look for next directory.
+ if (_stage == Stage.Directories && _directories?.Count > 0)
+ {
+ var name = _directories[0];
+ _directories = _directories[1..];
+ targetId = ModelUtils.ConstructIdForDirectory(name, _source.NodeId.NamespaceIndex);
+ }
+
+ // look for next file.
+ else if (_stage == Stage.Files && _files?.Count > 0)
+ {
+ var name = _files[0];
+ _files = _files[1..];
+ targetId = ModelUtils.ConstructIdForFile(name, _source.NodeId.NamespaceIndex);
+ }
+ }
+
+ // create reference.
+ if (targetId != null)
+ {
+ return new NodeStateReference(ReferenceTypeIds.HasComponent, false, targetId);
+ }
+
+ return null;
+ }
+
+ ///
+ /// The stages available in a browse operation.
+ ///
+ private enum Stage
+ {
+ Begin,
+ Directories,
+ Files,
+ Done
+ }
+
+ private Stage _stage;
+ private readonly DirectoryObjectState _source;
+ private List _files;
+ private List _directories;
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs
new file mode 100644
index 0000000000..19b59d6d36
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/DirectoryObjectState.cs
@@ -0,0 +1,304 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+
+ ///
+ /// A object which maps a segment to directory
+ ///
+ public class DirectoryObjectState : FileDirectoryState
+ {
+ ///
+ /// Gets the full path
+ ///
+ /// The segment path.
+ public string FullPath { get; }
+
+ ///
+ /// Is volume
+ ///
+ public bool IsVolume { get; }
+
+ ///
+ /// Create directory object
+ ///
+ ///
+ ///
+ ///
+ ///
+ public DirectoryObjectState(ISystemContext context, NodeId nodeId,
+ string path, bool isVolume) : base(null)
+ {
+ System.Diagnostics.Contracts.Contract.Assume(context != null);
+ FullPath = path;
+ IsVolume = isVolume;
+ TypeDefinitionId = ObjectTypeIds.FileDirectoryType;
+ SymbolicName = path;
+ NodeId = nodeId;
+ BrowseName = new QualifiedName(ModelUtils.GetName(path), nodeId.NamespaceIndex);
+ DisplayName = new LocalizedText(ModelUtils.GetName(path));
+ Description = null;
+ WriteMask = 0;
+ UserWriteMask = 0;
+ EventNotifier = EventNotifiers.None;
+
+ DeleteFileSystemObject = new DeleteFileMethodState(this);
+ DeleteFileSystemObject.OnCall = new DeleteFileMethodStateMethodCallHandler(OnDeleteFileSystemObject);
+ DeleteFileSystemObject.Executable = true;
+ DeleteFileSystemObject.UserExecutable = true;
+ DeleteFileSystemObject.Create(context, MethodIds.FileDirectoryType_DeleteFileSystemObject,
+ BrowseNames.DeleteFileSystemObject, BrowseNames.DeleteFileSystemObject, false);
+
+ CreateFile = new CreateFileMethodState(this);
+ CreateFile.OnCall = new CreateFileMethodStateMethodCallHandler(OnCreateFile);
+ CreateFile.Executable = true;
+ CreateFile.UserExecutable = true;
+ CreateFile.Create(context, MethodIds.FileDirectoryType_CreateFile,
+ BrowseNames.CreateFile, BrowseNames.CreateFile, false);
+
+ CreateDirectory = new CreateDirectoryMethodState(this);
+ CreateDirectory.OnCall = new CreateDirectoryMethodStateMethodCallHandler(OnCreateDirectory);
+ CreateDirectory.Executable = true;
+ CreateDirectory.UserExecutable = true;
+ CreateDirectory.Create(context, MethodIds.FileDirectoryType_CreateDirectory,
+ BrowseNames.CreateDirectory, BrowseNames.CreateDirectory, false);
+
+ MoveOrCopy = new MoveOrCopyMethodState(this);
+ MoveOrCopy.OnCall = new MoveOrCopyMethodStateMethodCallHandler(OnMoveOrCopy);
+ MoveOrCopy.Executable = true;
+ MoveOrCopy.UserExecutable = true;
+ MoveOrCopy.Create(context, MethodIds.FileDirectoryType_MoveOrCopy,
+ BrowseNames.MoveOrCopy, BrowseNames.MoveOrCopy, false);
+ }
+
+ private ServiceResult OnMoveOrCopy(ISystemContext _context, MethodState _method,
+ NodeId _objectId, NodeId objectToMoveOrCopy, NodeId targetDirectory, bool createCopy,
+ string newName, ref NodeId newNodeId)
+ {
+ var objectToMoveOrCopy2 = ParsedNodeId.Parse(objectToMoveOrCopy);
+ if (objectToMoveOrCopy2.RootType == ModelUtils.Volume)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidArgument,
+ "Source is not a directory or file");
+ }
+ var targetDirectory2 = ParsedNodeId.Parse(targetDirectory);
+ if (targetDirectory2.RootType != ModelUtils.Directory)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidArgument,
+ "Target is not a directory");
+ }
+ var path = objectToMoveOrCopy2.RootId;
+ var dst = Path.Combine(targetDirectory2.RootId, newName ?? Path.GetFileName(path));
+ try
+ {
+ if (File.Exists(path))
+ {
+ if (createCopy)
+ {
+ File.Copy(path, dst);
+ }
+ else
+ {
+ File.Move(path, dst);
+ }
+ newNodeId = ModelUtils.ConstructIdForFile(dst,
+ NodeId.NamespaceIndex);
+ }
+ else if (Directory.Exists(path))
+ {
+ if (createCopy)
+ {
+ CopyDirectory(path, dst);
+ }
+ else
+ {
+ Directory.Move(path, dst);
+ }
+ newNodeId = ModelUtils.ConstructIdForDirectory(dst,
+ NodeId.NamespaceIndex);
+ }
+ else
+ {
+ return ServiceResult.Create(StatusCodes.BadNotFound,
+ $"File sytem object {path} not found");
+ }
+ return ServiceResult.Good;
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
+ "Failed to move or copy");
+ }
+ }
+
+ private ServiceResult OnCreateDirectory(ISystemContext _context, MethodState _method,
+ NodeId _objectId, string directoryName, ref NodeId directoryNodeId)
+ {
+ var name = Path.Combine(FullPath, directoryName);
+ if (Path.Exists(name))
+ {
+ return ServiceResult.Create(StatusCodes.BadBrowseNameDuplicated,
+ "Directory or file with same name exists");
+ }
+ Directory.CreateDirectory(name);
+ directoryNodeId = ModelUtils.ConstructIdForDirectory(name, NodeId.NamespaceIndex);
+ return ServiceResult.Good;
+ }
+
+ private ServiceResult OnCreateFile(ISystemContext _context, MethodState _method,
+ NodeId _objectId, string fileName, bool requestFileOpen, ref NodeId fileNodeId,
+ ref uint fileHandle)
+ {
+ var name = Path.Combine(FullPath, fileName);
+ if (Path.Exists(name))
+ {
+ return ServiceResult.Create(StatusCodes.BadBrowseNameDuplicated,
+ "Directory or file with same name exists");
+ }
+ fileNodeId = ModelUtils.ConstructIdForFile(name, NodeId.NamespaceIndex);
+ if (requestFileOpen)
+ {
+ if (_context.SystemHandle is not FileSystem system ||
+ system.GetHandle(fileNodeId) is not FileHandle handle)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Failed to get handle");
+ }
+
+ return handle.Open(0x2, out fileHandle); // open for writing
+ }
+ try
+ {
+ using var f = File.Create(name);
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, null,
+ StatusCodes.BadUserAccessDenied);
+ }
+ fileHandle = 0;
+ return StatusCodes.Good;
+ }
+
+ private ServiceResult OnDeleteFileSystemObject(ISystemContext _context,
+ MethodState _method, NodeId _objectId, NodeId objectToDelete)
+ {
+ var objectToDelete2 = ParsedNodeId.Parse(objectToDelete);
+ var path = objectToDelete2.RootId;
+ try
+ {
+ switch (objectToDelete2.RootType)
+ {
+ case ModelUtils.File:
+ if (File.Exists(path))
+ {
+ File.Delete(path);
+ break;
+ }
+ return ServiceResult.Create(StatusCodes.BadNotFound,
+ $"File sytem object {path} not found");
+ case ModelUtils.Directory:
+ if (Directory.Exists(path))
+ {
+ Directory.Delete(path, true);
+ break;
+ }
+ return ServiceResult.Create(StatusCodes.BadNotFound,
+ $"File sytem object {path} not found");
+ case ModelUtils.Volume:
+ return ServiceResult.Create(StatusCodes.BadUserAccessDenied,
+ "Cannot delete root of filesystem");
+ default:
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Not a fileSystem object.");
+ }
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
+ "Failed to delete file system object.");
+ }
+ return ServiceResult.Good;
+ }
+
+ ///
+ /// Create browser on directory
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public override INodeBrowser CreateBrowser(
+ ISystemContext context, ViewDescription view, NodeId referenceType,
+ bool includeSubtypes, BrowseDirection browseDirection,
+ QualifiedName browseName, IEnumerable additionalReferences,
+ bool internalOnly)
+ {
+ NodeBrowser browser = new DirectoryBrowser(
+ context, view, referenceType, includeSubtypes,
+ browseDirection, browseName, additionalReferences,
+ internalOnly, this);
+
+ PopulateBrowser(context, browser);
+ return browser;
+ }
+
+ ///
+ /// Populates the browser with references that meet the criteria.
+ ///
+ /// The context for the system being accessed.
+ /// The browser to populate.
+ protected override void PopulateBrowser(ISystemContext context, NodeBrowser browser)
+ {
+ base.PopulateBrowser(context, browser);
+
+ // check if the parent segments need to be returned.
+ if (browser.IsRequired(ReferenceTypeIds.Organizes, true) && IsVolume)
+ {
+ // add reference to server
+ browser.Add(ReferenceTypeIds.Organizes, true, ObjectIds.Server);
+ }
+ else if (browser.IsRequired(ReferenceTypeIds.HasComponent, true) && !IsVolume)
+ {
+ var parent = Path.GetDirectoryName(FullPath);
+ if (Path.GetPathRoot(FullPath) == parent)
+ {
+ // add reference for parent volume.
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForVolume(parent, NodeId.NamespaceIndex));
+ }
+ else
+ {
+ // add reference to parent directory
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForDirectory(parent, NodeId.NamespaceIndex));
+ }
+ }
+ }
+
+ private static void CopyDirectory(string sourcePath, string targetPath)
+ {
+ foreach (string dirPath in Directory.GetDirectories(sourcePath, "*", SearchOption.AllDirectories))
+ {
+ Directory.CreateDirectory(dirPath.Replace(sourcePath, targetPath, StringComparison.InvariantCulture));
+ }
+ foreach (string newPath in Directory.GetFiles(sourcePath, "*", SearchOption.AllDirectories))
+ {
+ File.Copy(newPath, newPath.Replace(sourcePath, targetPath, StringComparison.InvariantCulture), true);
+ }
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileHandle.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileHandle.cs
new file mode 100644
index 0000000000..6f4312ea22
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileHandle.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 FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+
+ ///
+ /// File handle
+ ///
+ ///
+ public sealed record FileHandle(ParsedNodeId NodeId) : IDisposable
+ {
+ bool IsOpenForWrite => _write != null;
+ bool IsOpenForRead => _reads.Count > 0;
+
+ ///
+ /// Length
+ ///
+ public long Length => new FileInfo(NodeId.RootId).Length;
+
+ ///
+ /// Can be written to
+ ///
+ public bool IsWriteable => !IsOpenForRead && !IsOpenForWrite
+ && !new FileInfo(NodeId.RootId).IsReadOnly;
+
+ ///
+ /// Last modification
+ ///
+ public DateTime LastModifiedTime => File.GetLastWriteTimeUtc(NodeId.RootId);
+
+ ///
+ /// How many file handles are open
+ ///
+ public ushort OpenCount => (ushort)(_reads.Count + (IsOpenForWrite ? 1 : 0));
+
+ ///
+ /// Mime type
+ ///
+ public string MimeType { get; } = "text/plain"; // TODO
+
+ ///
+ /// Max byte string length
+ ///
+ public uint MaxByteStringLength { get; } = 4 * 1024; // TODO
+
+ ///
+ /// Get stream
+ ///
+ ///
+ ///
+ public Stream GetStream(uint fileHandle)
+ {
+ lock (_lock)
+ {
+ if (_write != null && fileHandle == 1)
+ {
+ return _write;
+ }
+ else if (_reads.TryGetValue(fileHandle, out var stream))
+ {
+ return stream;
+ }
+ return null;
+ }
+ }
+
+ ///
+ /// Open
+ ///
+ ///
+ ///
+ ///
+ public ServiceResult Open(byte mode, out uint fileHandle)
+ {
+ lock (_lock)
+ {
+ fileHandle = 0u;
+ try
+ {
+ if (mode == 0x1)
+ {
+ if (_write != null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File already open for write");
+ }
+ // read
+ var stream = new FileStream(NodeId.RootId,
+ FileMode.Open, FileAccess.Read);
+ fileHandle = ++_handles;
+ _reads.Add(fileHandle, stream);
+ }
+ else if ((mode & 0x2) != 0)
+ {
+ if (_reads.Count != 0 || _write != null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File already open for read or write");
+ }
+ if ((mode & 0x4) != 0)
+ {
+ // Erase = OpenOrCreate + Truncate
+ _write = new FileStream(NodeId.RootId,
+ FileMode.Create, FileAccess.Write);
+ }
+ else if ((mode & 0x8) != 0)
+ {
+ // Append
+ _write = new FileStream(NodeId.RootId,
+ FileMode.Append, FileAccess.Write);
+ }
+ else
+ {
+ // Open or create
+ _write = new FileStream(NodeId.RootId,
+ FileMode.OpenOrCreate, FileAccess.Write);
+ }
+ fileHandle = 1u;
+ }
+ else
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidArgument,
+ "Unknown mode value.");
+ }
+ }
+ catch (Exception ex)
+ {
+ return ServiceResult.Create(ex, StatusCodes.BadUserAccessDenied,
+ "Failed to open file");
+ }
+ }
+ return ServiceResult.Good;
+ }
+
+ public bool Close(uint fileHandle)
+ {
+ lock (_lock)
+ {
+ if (_write != null && fileHandle == 1)
+ {
+ _write.Dispose();
+ _write = null;
+ return true;
+ }
+ if (_reads.TryGetValue(fileHandle, out var stream))
+ {
+ stream.Dispose();
+ _reads.Remove(fileHandle);
+ return true;
+ }
+ }
+ return false;
+ }
+
+ public void Dispose()
+ {
+ _write?.Dispose();
+ foreach (var stream in _reads.Values)
+ {
+ stream.Dispose();
+ }
+ _reads.Clear();
+ }
+
+ private uint _handles = 1;
+ private readonly Dictionary _reads = new();
+ private readonly object _lock = new();
+ private Stream _write;
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs
new file mode 100644
index 0000000000..bd2b3011c9
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileObjectState.cs
@@ -0,0 +1,348 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using System;
+ using System.IO;
+
+ ///
+ /// A object which maps a segment to a UA object.
+ ///
+ public class FileObjectState : FileState
+ {
+ ///
+ /// Gets the path to the file
+ ///
+ /// The segment path.
+ public string FullPath { get; }
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The context.
+ /// The node id.
+ /// The segment.
+ public FileObjectState(ISystemContext context, NodeId nodeId, string path)
+ : base(null)
+ {
+ System.Diagnostics.Contracts.Contract.Assume(context != null);
+ FullPath = path;
+
+ TypeDefinitionId = ObjectTypeIds.FileType;
+ SymbolicName = path;
+ NodeId = nodeId;
+ BrowseName = new QualifiedName(Path.GetFileName(path),
+ nodeId.NamespaceIndex);
+ DisplayName = new LocalizedText(Path.GetFileName(path));
+ Description = null;
+ WriteMask = 0;
+ UserWriteMask = 0;
+ EventNotifier = EventNotifiers.None;
+
+ OpenCount = new PropertyState(this);
+ OpenCount.OnReadValue += OnOpenCount;
+ OpenCount.AccessLevel = AccessLevels.CurrentRead;
+ OpenCount.UserAccessLevel = AccessLevels.CurrentRead;
+ OpenCount.Create(context, VariableIds.FileType_OpenCount,
+ BrowseNames.OpenCount, BrowseNames.OpenCount, true);
+
+ Writable = new PropertyState(this);
+ Writable.OnReadValue += OnWritable;
+ Writable.AccessLevel = AccessLevels.CurrentRead;
+ Writable.UserAccessLevel = AccessLevels.CurrentRead;
+ Writable.Create(context, VariableIds.FileType_Writable,
+ BrowseNames.Writable, BrowseNames.Writable, true);
+
+ UserWritable = new PropertyState(this);
+ UserWritable.OnReadValue += OnWritable;
+ UserWritable.AccessLevel = AccessLevels.CurrentRead;
+ UserWritable.UserAccessLevel = AccessLevels.CurrentRead;
+ UserWritable.Create(context, VariableIds.FileType_UserWritable,
+ BrowseNames.UserWritable, BrowseNames.UserWritable, true);
+
+ Size = new PropertyState(this);
+ Size.OnReadValue += OnSize;
+ Size.AccessLevel = AccessLevels.CurrentRead;
+ Size.UserAccessLevel = AccessLevels.CurrentRead;
+ Size.Create(context, VariableIds.FileType_Size,
+ BrowseNames.Size, BrowseNames.Size, true);
+
+ MimeType = new PropertyState(this);
+ MimeType.OnReadValue += OnMimeType;
+ MimeType.AccessLevel = AccessLevels.CurrentRead;
+ MimeType.UserAccessLevel = AccessLevels.CurrentRead;
+ MimeType.Create(context, VariableIds.FileType_MimeType,
+ BrowseNames.MimeType, BrowseNames.MimeType, true);
+
+#if OPTIONAL_MAX_BYTE_STRING
+ MaxByteStringLength = new PropertyState(this);
+ MaxByteStringLength.OnReadValue += OnMaxByteStringLength;
+ MaxByteStringLength.AccessLevel = AccessLevels.CurrentRead;
+ MaxByteStringLength.UserAccessLevel = AccessLevels.CurrentRead;
+ MaxByteStringLength.Create(context, VariableIds.FileType_MaxByteStringLength,
+ BrowseNames.MaxByteStringLength, BrowseNames.MaxByteStringLength, true);
+#endif
+
+ LastModifiedTime = new PropertyState(this);
+ LastModifiedTime.OnReadValue += OnLastModifiedTime;
+ LastModifiedTime.AccessLevel = AccessLevels.CurrentRead;
+ LastModifiedTime.UserAccessLevel = AccessLevels.CurrentRead;
+ LastModifiedTime.Create(context, VariableIds.FileType_LastModifiedTime,
+ BrowseNames.LastModifiedTime, BrowseNames.LastModifiedTime, true);
+
+ Open = new OpenMethodState(this);
+ Open.OnCall = new OpenMethodStateMethodCallHandler(OnOpen);
+ Open.Executable = true;
+ Open.UserExecutable = true;
+ Open.Create(context, MethodIds.FileType_Open,
+ BrowseNames.Open, BrowseNames.Open, false);
+
+ Write = new WriteMethodState(this);
+ Write.OnCall = new WriteMethodStateMethodCallHandler(OnWrite);
+ Write.Executable = true;
+ Write.UserExecutable = true;
+ Write.Create(context, MethodIds.FileType_Write,
+ BrowseNames.Write, BrowseNames.Write, false);
+
+ Read = new ReadMethodState(this);
+ Read.OnCall = new ReadMethodStateMethodCallHandler(OnRead);
+ Read.Executable = true;
+ Read.UserExecutable = true;
+ Read.Create(context, MethodIds.FileType_Read,
+ BrowseNames.Read, BrowseNames.Read, false);
+
+ Close = new CloseMethodState(this);
+ Close.OnCall = new CloseMethodStateMethodCallHandler(OnClose);
+ Close.Executable = true;
+ Close.UserExecutable = true;
+ Close.Create(context, MethodIds.FileType_Close,
+ BrowseNames.Close, BrowseNames.Close, false);
+
+ GetPosition = new GetPositionMethodState(this);
+ GetPosition.OnCall = new GetPositionMethodStateMethodCallHandler(OnGetPosition);
+ GetPosition.Executable = true;
+ GetPosition.UserExecutable = true;
+ GetPosition.Create(context, MethodIds.FileType_GetPosition,
+ BrowseNames.GetPosition, BrowseNames.GetPosition, false);
+
+ SetPosition = new SetPositionMethodState(this);
+ SetPosition.OnCall = new SetPositionMethodStateMethodCallHandler(OnSetPosition);
+ SetPosition.Executable = true;
+ SetPosition.UserExecutable = true;
+ SetPosition.Create(context, MethodIds.FileType_SetPosition,
+ BrowseNames.SetPosition, BrowseNames.SetPosition, false);
+ }
+
+#if OPTIONAL_MAX_BYTE_STRING
+ private ServiceResult OnMaxByteStringLength(ISystemContext context,
+ NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
+ ref object value, ref StatusCode statusCode, ref DateTime timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out var handle, out var result))
+ {
+ value = handle.MaxByteStringLength;
+ timestamp = DateTime.UtcNow;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+#endif
+ private ServiceResult OnMimeType(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref object value,
+ ref StatusCode statusCode, ref DateTime timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out var handle, out var result))
+ {
+ value = handle.MimeType;
+ timestamp = DateTime.UtcNow;
+ statusCode = StatusCodes.Uncertain;
+ }
+ return result;
+ }
+
+ private ServiceResult OnLastModifiedTime(ISystemContext context,
+ NodeState node, NumericRange indexRange, QualifiedName dataEncoding,
+ ref object value, ref StatusCode statusCode, ref DateTime timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out var handle, out var result))
+ {
+ value = handle.LastModifiedTime;
+ timestamp = DateTime.UtcNow;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnWritable(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref object value,
+ ref StatusCode statusCode, ref DateTime timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out var handle, out var result))
+ {
+ value = handle.IsWriteable;
+ timestamp = DateTime.UtcNow;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnSize(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref object value,
+ ref StatusCode statusCode, ref DateTime timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out var handle, out var result))
+ {
+ value = handle.Length;
+ timestamp = DateTime.UtcNow;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnOpenCount(ISystemContext context, NodeState node,
+ NumericRange indexRange, QualifiedName dataEncoding, ref object value,
+ ref StatusCode statusCode, ref DateTime timestamp)
+ {
+ if (GetFileHandle(context, NodeId, out var handle, out var result))
+ {
+ value = handle.OpenCount;
+ timestamp = DateTime.UtcNow;
+ statusCode = StatusCodes.Good;
+ }
+ return result;
+ }
+
+ private ServiceResult OnOpen(ISystemContext _context, MethodState _method,
+ NodeId _objectId, byte mode, ref uint fileHandle)
+ {
+ if (GetFileHandle(_context, _objectId, out var handle, out var result))
+ {
+ result = handle.Open(mode, out fileHandle);
+ }
+ return result;
+ }
+
+ private ServiceResult OnClose(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle)
+ {
+ if (GetFileHandle(_context, _objectId, out var handle, out var result)
+ && !handle.Close(fileHandle))
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle could not be closed.");
+ }
+ return result;
+ }
+
+ private ServiceResult OnSetPosition(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle, ulong position)
+ {
+ if (GetFileHandle(_context, _objectId, out var handle, out var result))
+ {
+ var stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle not open.");
+ }
+ stream.Position = (long)position;
+ }
+ return result;
+ }
+
+ private ServiceResult OnGetPosition(ISystemContext _context,
+ MethodState _method, NodeId _objectId, uint fileHandle, ref ulong position)
+ {
+ if (GetFileHandle(_context, _objectId, out var handle, out var result))
+ {
+ var stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle not open.");
+ }
+ position = (ulong)stream.Position;
+ }
+ return result;
+ }
+
+ private ServiceResult OnRead(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle, int length, ref byte[] data)
+ {
+ if (GetFileHandle(_context, _objectId, out var handle, out var result))
+ {
+ var stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return ServiceResult.Create(StatusCodes.BadInvalidState,
+ "File handle not open.");
+ }
+ var buffer = new Span(new byte[length]);
+ var read = stream.Read(buffer);
+ data = buffer.Slice(0, read).ToArray();
+ }
+ return result;
+ }
+
+ private ServiceResult OnWrite(ISystemContext _context, MethodState _method,
+ NodeId _objectId, uint fileHandle, byte[] data)
+ {
+ if (GetFileHandle(_context, _objectId, out var handle, out var result))
+ {
+ var stream = handle.GetStream(fileHandle);
+ if (stream == null)
+ {
+ return StatusCodes.BadInvalidState;
+ }
+ stream.Write(data.AsSpan());
+ }
+ return result;
+ }
+
+ ///
+ /// Populates the browser with references that meet the criteria.
+ ///
+ /// The context for the system being accessed.
+ /// The browser to populate.
+ protected override void PopulateBrowser(ISystemContext context, NodeBrowser browser)
+ {
+ base.PopulateBrowser(context, browser);
+
+ // check if the parent segments need to be returned.
+ if (browser.IsRequired(ReferenceTypeIds.HasComponent, true))
+ {
+ var directory = Path.GetDirectoryName(FullPath);
+ if (Path.GetPathRoot(FullPath) == directory)
+ {
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForVolume(directory, NodeId.NamespaceIndex));
+ }
+ else
+ {
+ browser.Add(ReferenceTypeIds.HasComponent, true,
+ ModelUtils.ConstructIdForDirectory(directory, NodeId.NamespaceIndex));
+ }
+ }
+ }
+
+ private static bool GetFileHandle(ISystemContext context, NodeId nodeId,
+ out FileHandle handle, out ServiceResult result)
+ {
+ if (context.SystemHandle is not FileSystem system ||
+ system.GetHandle(nodeId) is not FileHandle h)
+ {
+ result = ServiceResult.Create(StatusCodes.BadInvalidState,
+ "Object is not a file.");
+ handle = default;
+ return false;
+ }
+ handle = h;
+ result = ServiceResult.Good;
+ return true;
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystem.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystem.cs
new file mode 100644
index 0000000000..c548504bed
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystem.cs
@@ -0,0 +1,52 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System;
+ using System.Collections.Generic;
+
+ ///
+ /// File system and handle management
+ ///
+ public sealed class FileSystem : IDisposable
+ {
+ public void Dispose()
+ {
+ lock (_syncRoot)
+ {
+ foreach (var handle in _handles.Values)
+ {
+ handle.Dispose();
+ }
+ _handles.Clear();
+ }
+ }
+
+ public FileHandle GetHandle(NodeId nodeId)
+ {
+ lock (_syncRoot)
+ {
+ if (_handles.TryGetValue(nodeId, out var handle))
+ {
+ return handle;
+ }
+ var parsed = ParsedNodeId.Parse(nodeId);
+ if (parsed == null || parsed.RootType != ModelUtils.File)
+ {
+ return null;
+ }
+ handle = new FileHandle(parsed);
+ _handles.Add(nodeId, handle);
+ return handle;
+ }
+ }
+
+ private readonly object _syncRoot = new();
+ private readonly Dictionary _handles = new();
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs
new file mode 100644
index 0000000000..e66a317763
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemNodeManager.cs
@@ -0,0 +1,303 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+
+ ///
+ /// A node manager for a server that exposes several variables.
+ ///
+ public class FileSystemNodeManager : CustomNodeManager2
+ {
+ ///
+ /// Initializes the node manager.
+ ///
+ ///
+ ///
+ public FileSystemNodeManager(IServerInternal server, ApplicationConfiguration configuration) :
+ base(server, configuration, Namespaces.FileSystem)
+ {
+ SystemContext.SystemHandle = _system = new FileSystem();
+ SystemContext.NodeIdFactory = this;
+
+ var namespaceUris = new List {
+ Namespaces.FileSystem
+ };
+ NamespaceUris = namespaceUris;
+
+ _namespaceIndex = Server.NamespaceUris.GetIndexOrAppend(namespaceUris[0]);
+ // get the configuration for the node manager.
+ // use suitable defaults if no configuration exists.
+ _configuration = configuration.ParseExtension() ??
+ new FileSystemServerConfiguration();
+ }
+
+ ///
+ /// An overrideable version of the Dispose.
+ ///
+ ///
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ _system.Dispose();
+ }
+ base.Dispose(disposing);
+ }
+
+ ///
+ /// Creates the NodeId for the specified node.
+ ///
+ /// The context.
+ /// The node.
+ /// The new NodeId.
+ ///
+ /// This method is called by the NodeState.Create() method which initializes a Node from
+ /// the type model. During initialization a number of child nodes are created and need to
+ /// have NodeIds assigned to them. This implementation constructs NodeIds by constructing
+ /// strings. Other implementations could assign unique integers or Guids and save the new
+ /// Node in a dictionary for later lookup.
+ ///
+ public override NodeId New(ISystemContext context, NodeState node)
+ {
+ return ModelUtils.ConstructIdForComponent(node, NamespaceIndex);
+ }
+
+ ///
+ /// Does any initialization required before the address space can be used.
+ ///
+ ///
+ ///
+ /// The externalReferences is an out parameter that allows the node manager to link to nodes
+ /// in other node managers. For example, the 'Objects' node is managed by the CoreNodeManager and
+ /// should have a reference to the root folder node(s) exposed by this node manager.
+ ///
+ public override void CreateAddressSpace(IDictionary> externalReferences)
+ {
+ lock (Lock)
+ {
+ // find the top level segments and link them to the Server folder.
+ foreach (var fs in DriveInfo.GetDrives())
+ {
+ if (!externalReferences.TryGetValue(ObjectIds.Server, out var references))
+ {
+ externalReferences[ObjectIds.Server] = references = new List();
+ }
+
+ // construct the NodeId of a segment.
+ var fsId = ModelUtils.ConstructIdForVolume(fs.RootDirectory.FullName, _namespaceIndex);
+
+ // add an organizes reference from the server to the volume.
+ references.Add(new NodeStateReference(ReferenceTypeIds.Organizes, false, fsId));
+ }
+ }
+ }
+
+ ///
+ /// Frees any resources allocated for the address space.
+ ///
+ public override void DeleteAddressSpace()
+ {
+ lock (Lock)
+ {
+ }
+ }
+
+ ///
+ /// Returns a unique handle for the node.
+ ///
+ ///
+ ///
+ ///
+ protected override NodeHandle GetManagerHandle(ServerSystemContext context, NodeId nodeId,
+ IDictionary cache)
+ {
+ lock (Lock)
+ {
+ // quickly exclude nodes that are not in the namespace.
+ if (!IsNodeIdInNamespace(nodeId))
+ {
+ return null;
+ }
+
+ if (nodeId.IdType != IdType.String && PredefinedNodes.TryGetValue(nodeId, out var node))
+ {
+ return new NodeHandle
+ {
+ NodeId = nodeId,
+ Node = node,
+ Validated = true
+ };
+ }
+
+ // parse the identifier.
+ var parsedNodeId = ParsedNodeId.Parse(nodeId);
+
+ if (parsedNodeId != null)
+ {
+ return new NodeHandle
+ {
+ NodeId = nodeId,
+ Validated = false,
+ Node = null,
+ ParsedNodeId = parsedNodeId
+ };
+ }
+
+ return null;
+ }
+ }
+
+ ///
+ /// Verifies that the specified node exists.
+ ///
+ ///
+ ///
+ ///
+ protected override NodeState ValidateNode(ServerSystemContext context,
+ NodeHandle handle, IDictionary cache)
+ {
+ // not valid if no root.
+ if (handle == null)
+ {
+ return null;
+ }
+
+ // check if previously validated.
+ if (handle.Validated)
+ {
+ return handle.Node;
+ }
+
+ NodeState target = null;
+
+ // check if already in the cache.
+ if (cache != null)
+ {
+ if (cache.TryGetValue(handle.NodeId, out target))
+ {
+ // nulls mean a NodeId which was previously found to be invalid has been referenced again.
+ if (target == null)
+ {
+ return null;
+ }
+
+ handle.Node = target;
+ handle.Validated = true;
+ return handle.Node;
+ }
+
+ target = null;
+ }
+
+ try
+ {
+ // check if the node id has been parsed.
+ if (handle.ParsedNodeId is not ParsedNodeId parsedNodeId)
+ {
+ return null;
+ }
+
+ NodeState root = null;
+
+ // Validate drive
+ if (parsedNodeId.RootType == ModelUtils.Volume)
+ {
+ var volume = DriveInfo.GetDrives().FirstOrDefault(d => d.RootDirectory.FullName == parsedNodeId.RootId);
+
+ // volume does not exist.
+ if (volume == null)
+ {
+ return null;
+ }
+
+ var rootId = ModelUtils.ConstructIdForVolume(volume.RootDirectory.FullName, _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);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+
+ // Validate directory
+ else if (parsedNodeId.RootType == ModelUtils.Directory)
+ {
+ // block does not exist.
+ if (!Path.Exists(parsedNodeId.RootId))
+ {
+ return null;
+ }
+
+ var rootId = ModelUtils.ConstructIdForDirectory(parsedNodeId.RootId, _namespaceIndex);
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ root = new DirectoryObjectState(context, rootId, parsedNodeId.RootId, false);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+
+ // Validate file
+ else if (parsedNodeId.RootType == ModelUtils.File)
+ {
+ // block does not exist.
+ if (!Path.Exists(parsedNodeId.RootId))
+ {
+ return null;
+ }
+
+ var rootId = ModelUtils.ConstructIdForFile(parsedNodeId.RootId, _namespaceIndex);
+
+#pragma warning disable CA2000 // Dispose objects before losing scope
+ root = new FileObjectState(context, rootId, parsedNodeId.RootId);
+#pragma warning restore CA2000 // Dispose objects before losing scope
+ }
+
+ // unknown root type.
+ else
+ {
+ return null;
+ }
+
+ // all done if no components to validate.
+ if (string.IsNullOrEmpty(parsedNodeId.ComponentPath))
+ {
+ handle.Validated = true;
+ handle.Node = target = root;
+ return handle.Node;
+ }
+
+ // validate component.
+ NodeState component = root.FindChildBySymbolicName(context, parsedNodeId.ComponentPath);
+
+ // component does not exist.
+ if (component == null)
+ {
+ return null;
+ }
+
+ // found a valid component.
+ handle.Validated = true;
+ handle.Node = target = component;
+ return handle.Node;
+ }
+ finally
+ {
+ // store the node in the cache to optimize subsequent lookups.
+ cache?.Add(handle.NodeId, target);
+ }
+ }
+
+ private readonly ushort _namespaceIndex;
+
+#pragma warning disable IDE0052 // Remove unread private members
+ private readonly FileSystemServerConfiguration _configuration;
+ private readonly FileSystem _system;
+#pragma warning restore IDE0052 // Remove unread private members
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemServer.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemServer.cs
new file mode 100644
index 0000000000..6a90f7fac1
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemServer.cs
@@ -0,0 +1,32 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+
+ ///
+ public class FileSystemServer : INodeManagerFactory
+ {
+ ///
+ public StringCollection NamespacesUris
+ {
+ get
+ {
+ return new StringCollection {
+ Namespaces.FileSystem
+ };
+ }
+ }
+
+ ///
+ public INodeManager Create(IServerInternal server,
+ ApplicationConfiguration configuration)
+ {
+ return new FileSystemNodeManager(server, configuration);
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemServerConfiguration.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemServerConfiguration.cs
new file mode 100644
index 0000000000..cd7c66096b
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/FileSystemServerConfiguration.cs
@@ -0,0 +1,23 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using System.Runtime.Serialization;
+
+ ///
+ /// Stores the configuration the file system node manager.
+ ///
+ [DataContract(Namespace = Namespaces.FileSystem)]
+ public class FileSystemServerConfiguration
+ {
+ ///
+ /// The default constructor.
+ ///
+ public FileSystemServerConfiguration()
+ {
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/ModelUtils.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/ModelUtils.cs
new file mode 100644
index 0000000000..e8b25db4ff
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/ModelUtils.cs
@@ -0,0 +1,142 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ using Opc.Ua;
+ using Opc.Ua.Server;
+ using System.IO;
+ using System.Text;
+
+ ///
+ /// A class that builds NodeIds used by the FileSystem NodeManager
+ ///
+ public static class ModelUtils
+ {
+ ///
+ /// The RootType for a Volume node identfier.
+ ///
+ public const int Volume = 0;
+
+ ///
+ /// The RootType for a Directory node identfier.
+ ///
+ public const int Directory = 1;
+
+ ///
+ /// The RootType for a File node identfier.
+ ///
+ public const int File = 2;
+
+ ///
+ /// Create id for drive
+ ///
+ ///
+ ///
+ ///
+ public static NodeId ConstructIdForVolume(string path, ushort namespaceIndex)
+ {
+ var parsedNodeId = new ParsedNodeId
+ {
+ RootId = path,
+ NamespaceIndex = namespaceIndex,
+ RootType = 0
+ };
+ return parsedNodeId.Construct();
+ }
+
+ ///
+ /// Constructs a NodeId a file or directory.
+ ///
+ /// The directory.
+ /// Index of the namespace.
+ /// The new NodeId.
+ public static NodeId ConstructIdForDirectory(string path, ushort namespaceIndex)
+ {
+ var parsedNodeId = new ParsedNodeId
+ {
+ RootId = path,
+ NamespaceIndex = namespaceIndex,
+ RootType = 1
+ };
+ return parsedNodeId.Construct();
+ }
+
+ ///
+ /// Constructs a NodeId a file or directory.
+ ///
+ /// The file.
+ /// Index of the namespace.
+ /// The new NodeId.
+ public static NodeId ConstructIdForFile(string path, ushort namespaceIndex)
+ {
+ var parsedNodeId = new ParsedNodeId
+ {
+ RootId = path,
+ NamespaceIndex = namespaceIndex,
+ RootType = 2
+ };
+ return parsedNodeId.Construct();
+ }
+
+ public static string GetName(string path)
+ {
+ var name = Path.GetFileName(path);
+ if (string.IsNullOrEmpty(name))
+ {
+ return path;
+ }
+ return name;
+ }
+
+ ///
+ /// Constructs the node identifier for a component.
+ ///
+ /// The component.
+ /// Index of the namespace.
+ /// The node identifier for a component.
+ public static NodeId ConstructIdForComponent(NodeState component, ushort namespaceIndex)
+ {
+ if (component == null)
+ {
+ return null;
+ }
+
+ // components must be instances with a parent.
+
+ if (component is not BaseInstanceState instance || instance.Parent == null)
+ {
+ return component.NodeId;
+ }
+
+ // parent must have a string identifier.
+
+ if (instance.Parent.NodeId.Identifier is not string parentId)
+ {
+ return null;
+ }
+
+ var buffer = new StringBuilder();
+ buffer.Append(parentId);
+
+ // check if the parent is another component.
+ var index = parentId.IndexOf('?', System.StringComparison.InvariantCulture);
+
+ if (index < 0)
+ {
+ buffer.Append('?');
+ }
+ else
+ {
+ buffer.Append('/');
+ }
+
+ buffer.Append(component.SymbolicName);
+
+ // return the node identifier.
+ return new NodeId(buffer.ToString(), namespaceIndex);
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/Namespaces.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/Namespaces.cs
new file mode 100644
index 0000000000..471956361c
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/FileSystem/Namespaces.cs
@@ -0,0 +1,18 @@
+// ------------------------------------------------------------
+// Copyright (c) Microsoft Corporation. All rights reserved.
+// Licensed under the MIT License (MIT). See License.txt in the repo root for license information.
+// ------------------------------------------------------------
+
+namespace FileSystem
+{
+ ///
+ /// Defines constants for namespaces used by the application.
+ ///
+ public static class Namespaces
+ {
+ ///
+ /// The namespace for the nodes provided by the server.
+ ///
+ public const string FileSystem = "FileSystem";
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs
index 05a39e3a77..346c46c57b 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/src/ServerFactory.cs
@@ -96,7 +96,8 @@ public ServerFactory(ILogger logger, uint scaleunits = 0) :
new DataAccess.DataAccessServer(),
new Alarms.AlarmConditionServer(new TimeService()),
new SimpleEvents.SimpleEventsServer(),
- new Plc.PlcServer(new TimeService(), logger, scaleunits)
+ new Plc.PlcServer(new TimeService(), logger, scaleunits),
+ new FileSystem.FileSystemServer()
// new PerfTest.PerfTestServer(),
})
{
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 a1351277e3..979cf9bb6e 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 4511963a2b..cb92e6c7d1 100644
--- a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/BaseServerFixture.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/BaseServerFixture.cs
@@ -74,6 +74,11 @@ public IOpcUaClientManager Client
///
public TimeService TimeService => _timeService.Object;
+ ///
+ /// Temporary path
+ ///
+ public string TempPath { get; } = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
+
///
/// Filter parser
///
@@ -277,6 +282,11 @@ protected virtual void Dispose(bool disposing)
Try.Op(() => Directory.Delete(pkiPath, true));
}
logger.LogInformation("Disposing Server took {Elapsed}...", sw.Elapsed);
+
+ if (Directory.Exists(TempPath))
+ {
+ Try.Op(() => Directory.Delete(TempPath, true));
+ }
}
_disposedValue = true;
}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/FileSystemServer.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/FileSystemServer.cs
new file mode 100644
index 0000000000..78008d8b6f
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Fixtures/FileSystemServer.cs
@@ -0,0 +1,46 @@
+// ------------------------------------------------------------
+// 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.Fixtures
+{
+ using Microsoft.Extensions.Logging;
+ using Opc.Ua.Server;
+ using Opc.Ua.Test;
+ using System.Collections.Generic;
+
+ ///
+ /// Sample server fixture
+ ///
+ public class FileSystemServer : BaseServerFixture
+ {
+ ///
+ /// Sample server nodes
+ ///
+ ///
+ ///
+ public static IEnumerable TestData(
+ ILoggerFactory? factory, TimeService timeservice)
+ {
+ yield return new FileSystem.FileSystemServer();
+ }
+
+ ///
+ public FileSystemServer() : base(TestData)
+ {
+ }
+
+ ///
+ private FileSystemServer(ILoggerFactory loggerFactory)
+ : base(TestData, loggerFactory)
+ {
+ }
+
+ ///
+ public static FileSystemServer Create(ILoggerFactory loggerFactory)
+ {
+ return new FileSystemServer(loggerFactory);
+ }
+ }
+}
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
new file mode 100644
index 0000000000..f06e31e700
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/BrowseTests.cs
@@ -0,0 +1,396 @@
+// ------------------------------------------------------------
+// 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.Models;
+ using System;
+ using System.Collections.Generic;
+ using System.Globalization;
+ using System.IO;
+ using System.Linq;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Xunit;
+
+ public class BrowseTests
+ {
+ ///
+ /// Create metadata tests
+ ///
+ ///
+ ///
+ ///
+ public BrowseTests(Func> services, T connection, string tempPath)
+ {
+ _services = services;
+ _connection = connection;
+ _tempPath = tempPath;
+ }
+
+ 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))
+ {
+ Assert.NotNull(fs.ErrorInfo);
+ Assert.Equal(0u, fs.ErrorInfo.StatusCode);
+ Assert.NotNull(fs.Result);
+ Assert.NotNull(fs.Result.Name);
+ Assert.Contains(drives, d => d.RootDirectory.FullName == fs.Result?.Name);
+ found.Add(fs.Result.Name);
+ }
+ // TODO: Assert.True(drives.Count <= found.Count);
+ }
+
+ public async Task GetDirectoriesTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+ Assert.Null(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ Assert.Empty(directories.Result);
+ }
+
+ public async Task GetDirectoriesTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var path2 = Path.Combine(path, Path.GetRandomFileName());
+ Directory.CreateDirectory(path2);
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ var item = Assert.Single(directories.Result);
+ Assert.NotNull(item);
+ Assert.Equal(Path.GetFileName(path2), item.Name);
+ }
+
+ public async Task GetDirectoriesTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ for (var i = 0; i < 10; i++)
+ {
+ var path2 = Path.Combine(path, i.ToString(CultureInfo.InvariantCulture));
+ Directory.CreateDirectory(path2);
+ }
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ var result = directories.Result.ToList();
+ Assert.Equal(10, result.Count);
+ Assert.All(result, item => Assert.NotNull(item.Name));
+ Assert.All(result.Select(r => r.Name).Order(),
+ (item, i) => Assert.Equal(i.ToString(CultureInfo.InvariantCulture), item));
+ }
+
+ public async Task GetDirectoriesTest4Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ for (var i = 0; i < 10; i++)
+ {
+ CreateFile(path, i.ToString(CultureInfo.InvariantCulture), 1024);
+ }
+
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ Assert.Empty(directories.Result);
+ }
+
+ public async Task GetDirectoriesTest5Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ Assert.Empty(directories.Result);
+ Assert.Equal(Opc.Ua.StatusCodes.BadNodeIdUnknown, directories.ErrorInfo.StatusCode);
+ }
+
+ public async Task GetDirectoriesTest6Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var p3 = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2, p3);
+ Directory.CreateDirectory(path);
+ var path2 = Path.Combine(path, Path.GetRandomFileName());
+ Directory.CreateDirectory(path2);
+
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{root}",
+ BrowsePath = new List { $"nsu=FileSystem;{p1}", $"nsu=FileSystem;{p2}", $"nsu=FileSystem;{p3}" }
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ var item = Assert.Single(directories.Result);
+ Assert.NotNull(item);
+ Assert.Equal(Path.GetFileName(path2), item.Name);
+ }
+
+ public async Task GetFilesTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ for (var i = 10; i < 20; i++)
+ {
+ var path2 = Path.Combine(path, i.ToString(CultureInfo.InvariantCulture));
+ Directory.CreateDirectory(path2);
+ }
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ Assert.Empty(files.Result);
+ }
+
+ public async Task GetFilesTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+
+ for (var i = 0; i < 10; i++)
+ {
+ CreateFile(path, i.ToString(CultureInfo.InvariantCulture), 1024);
+ }
+
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ var result = files.Result.ToList();
+ Assert.Equal(10, result.Count);
+ Assert.All(result, item => Assert.NotNull(item.Name));
+ Assert.All(result.Select(r => r.Name).Order(),
+ (item, i) => Assert.Equal(i.ToString(CultureInfo.InvariantCulture), item));
+ }
+
+ public async Task GetFilesTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ for (var i = 0; i < 10; i++)
+ {
+ CreateFile(path, i.ToString(CultureInfo.InvariantCulture), 1024);
+ }
+ for (var i = 10; i < 20; i++)
+ {
+ var path2 = Path.Combine(path, i.ToString(CultureInfo.InvariantCulture));
+ Directory.CreateDirectory(path2);
+ }
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ var result = files.Result.ToList();
+ Assert.Equal(10, result.Count);
+ Assert.All(result, item => Assert.NotNull(item.Name));
+ Assert.All(result.Select(r => r.Name).Order(),
+ (item, i) => Assert.Equal(i.ToString(CultureInfo.InvariantCulture), item));
+ }
+
+ public async Task GetFilesTest4Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ for (var i = 0; i < 5; i++)
+ {
+ CreateFile(path, i.ToString(CultureInfo.InvariantCulture), 1024);
+ }
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ var result = files.Result.ToList();
+ Assert.Equal(5, result.Count);
+ Assert.All(result, item => Assert.NotNull(item.Name));
+ Assert.All(result.Select(r => r.Name).Order(),
+ (item, i) => Assert.Equal(i.ToString(CultureInfo.InvariantCulture), item));
+
+ for (var i = 5; i < 10; i++)
+ {
+ CreateFile(path, i.ToString(CultureInfo.InvariantCulture), 1024);
+ }
+
+ files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ result = files.Result.ToList();
+ Assert.Equal(10, result.Count);
+ Assert.All(result, item => Assert.NotNull(item.Name));
+ Assert.All(result.Select(r => r.Name).Order(),
+ (item, i) => Assert.Equal(i.ToString(CultureInfo.InvariantCulture), item));
+
+ for (var i = 0; i < 6; i++)
+ {
+ var path2 = Path.Combine(path, i.ToString(CultureInfo.InvariantCulture));
+ File.Delete(path2);
+ }
+
+ files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ result = files.Result.ToList();
+ Assert.Equal(4, result.Count);
+ Assert.All(result, item => Assert.NotNull(item.Name));
+ Assert.All(result.Select(r => r.Name).Order(),
+ (item, i) => Assert.Equal((i + 6).ToString(CultureInfo.InvariantCulture), item));
+
+ foreach (var file in Directory.GetFiles(path))
+ {
+ File.Delete(file);
+ }
+ files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ Assert.Empty(files.Result);
+ }
+
+ public async Task GetFilesTest5Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(files.Result);
+ Assert.Empty(files.Result);
+ Assert.NotNull(files.ErrorInfo);
+ Assert.Equal(Opc.Ua.StatusCodes.BadNodeIdUnknown, files.ErrorInfo.StatusCode);
+ }
+
+ public async Task GetFilesTest6Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var p3 = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2, p3);
+ Directory.CreateDirectory(path);
+ CreateFile(path, "test", 1000);
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{root}",
+ BrowsePath = new List { $"nsu=FileSystem;{p1}", $"nsu=FileSystem;{p2}", $"nsu=FileSystem;{p3}" }
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ var item = Assert.Single(files.Result);
+ Assert.Equal("test", item.Name);
+ }
+
+ private static string CreateFile(string path, string name, long length)
+ {
+ var fullPath = Path.Combine(path, name);
+ using var f = File.Create(fullPath);
+ var buffer = new byte[length];
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ buffer[i] = (byte)i;
+ }
+ f.Write(buffer);
+ return fullPath;
+ }
+
+ private readonly T _connection;
+ private readonly string _tempPath;
+ private readonly Func> _services;
+ }
+}
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
new file mode 100644
index 0000000000..beed5c4729
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/OperationsTests.cs
@@ -0,0 +1,501 @@
+// ------------------------------------------------------------
+// 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.Models;
+ using Opc.Ua;
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Xunit;
+
+ public class OperationsTests
+ {
+ ///
+ /// Create tests
+ ///
+ ///
+ ///
+ ///
+ public OperationsTests(Func> services, T connection, string tempPath)
+ {
+ _services = services;
+ _connection = connection;
+ _tempPath = tempPath;
+ }
+
+ public async Task CreateDirectoryTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var directory = await services.CreateDirectoryAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, "testdirectory", ct).ConfigureAwait(false);
+
+ Assert.Null(directory.ErrorInfo);
+ Assert.True(Directory.Exists(Path.Combine(path, "testdirectory")));
+
+ var directories = await services.GetDirectoriesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(directories.ErrorInfo);
+ Assert.NotNull(directories.Result);
+ var item = Assert.Single(directories.Result);
+ Assert.Equal(item, directory.Result);
+ }
+
+ public async Task CreateDirectoryTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ Directory.CreateDirectory(Path.Combine(path, "testdirectory"));
+
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var directory = await services.CreateDirectoryAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, "testdirectory", ct).ConfigureAwait(false);
+
+ Assert.NotNull(directory.ErrorInfo);
+ Assert.Equal(Opc.Ua.StatusCodes.BadBrowseNameDuplicated, directory.ErrorInfo.StatusCode);
+ }
+
+ public async Task CreateDirectoryTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var p3 = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2, p3);
+ Directory.CreateDirectory(path);
+ Assert.Empty(Directory.GetDirectories(path));
+
+ var directory = await services.CreateDirectoryAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{root}",
+ BrowsePath = new List { $"nsu=FileSystem;{p1}", $"nsu=FileSystem;{p2}", $"nsu=FileSystem;{p3}" }
+ }, "testdir", ct).ConfigureAwait(false);
+
+ Assert.Null(directory.ErrorInfo);
+ Assert.NotEmpty(Directory.GetDirectories(path));
+ Assert.True(Directory.Exists(Path.Combine(path, "testdir")));
+ }
+
+ public async Task CreateDirectoryTest4Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var p3 = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2, p3);
+ Directory.CreateDirectory(path);
+ Assert.Empty(Directory.GetDirectories(path));
+
+ var directory = await services.CreateDirectoryAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{root}",
+ BrowsePath = new List { $"nsu=FileSystem;{p1}", $"nsu=FileSystem;{p2}", "nsu=FileSystem;Bad" }
+ }, "testdir", ct).ConfigureAwait(false);
+
+ Assert.NotNull(directory.ErrorInfo);
+ Assert.Equal(Opc.Ua.StatusCodes.BadNotFound, directory.ErrorInfo.StatusCode);
+ Assert.Empty(Directory.GetDirectories(path));
+ }
+
+ public async Task DeleteDirectoryTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var path2 = Path.Combine(path, "testDirectory");
+ Directory.CreateDirectory(Path.Combine(path, "testDirectory"));
+
+ var parentDirectoryId = $"nsu=FileSystem;s=1:{path}";
+
+ Assert.NotEmpty(Directory.GetDirectories(path));
+
+ var nodeToDelete = $"nsu=FileSystem;s=1:{path2}";
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = nodeToDelete
+ },
+ new FileSystemObjectModel
+ {
+ NodeId = parentDirectoryId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(0u, result.StatusCode);
+ Assert.Empty(Directory.GetDirectories(path));
+ }
+
+ public async Task DeleteDirectoryTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var path2 = Path.Combine(path, "testDirectory");
+ Directory.CreateDirectory(Path.Combine(path, "testDirectory"));
+
+ Assert.NotEmpty(Directory.GetDirectories(path));
+
+ var nodeToDelete = $"nsu=FileSystem;s=1:{path2}";
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = nodeToDelete
+ }, ct: ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(0u, result.StatusCode);
+ Assert.Empty(Directory.GetDirectories(path));
+ }
+
+ public async Task DeleteDirectoryTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var parentDirectoryId = $"nsu=FileSystem;s=1:{path}";
+
+ var fileToDeleteId = $"nsu=FileSystem;s=1:{Path.Combine(path, "wrong")}";
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = fileToDeleteId
+ },
+ new FileSystemObjectModel
+ {
+ NodeId = parentDirectoryId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(Opc.Ua.StatusCodes.BadNotFound, result.StatusCode);
+ }
+
+ public async Task CreateFileTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var file = await services.CreateFileAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, "testfile", ct).ConfigureAwait(false);
+
+ Assert.Null(file.ErrorInfo);
+ Assert.True(File.Exists(Path.Combine(path, "testfile")));
+
+ var files = await services.GetFilesAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(files.ErrorInfo);
+ Assert.NotNull(files.Result);
+ var item = Assert.Single(files.Result);
+ Assert.Equal(item, file.Result);
+ }
+
+ public async Task CreateFileTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var directoryNodeId = $"nsu=FileSystem;s=1:{path}";
+ var file = await services.CreateFileAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, "testfile", ct).ConfigureAwait(false);
+
+ Assert.Null(file.ErrorInfo);
+ Assert.True(File.Exists(Path.Combine(path, "testfile")));
+
+ var file2 = await services.CreateFileAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = directoryNodeId
+ }, "testfile", ct).ConfigureAwait(false);
+
+ Assert.Null(file2.Result);
+ Assert.NotNull(file2.ErrorInfo);
+ Assert.Equal(Opc.Ua.StatusCodes.BadBrowseNameDuplicated, file2.ErrorInfo.StatusCode);
+ }
+
+ public async Task CreateFileTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var f = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2);
+ Directory.CreateDirectory(path);
+
+ Assert.Empty(Directory.GetFiles(path));
+
+ var file = await services.CreateFileAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{root}",
+ BrowsePath = new List { $"nsu=FileSystem;{p1}", $"nsu=FileSystem;{p2}" }
+ }, "testfile", ct).ConfigureAwait(false);
+
+ Assert.Null(file.ErrorInfo);
+ Assert.True(File.Exists(Path.Combine(path, "testfile")));
+ }
+
+ public async Task CreateFileTest4Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var f = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2);
+ Directory.CreateDirectory(path);
+
+ Assert.Empty(Directory.GetFiles(path));
+
+ var file = await services.CreateFileAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{root}",
+ BrowsePath = new List { $"nsu=FileSystem;{p1}", "nsu=FileSystem;Bad" }
+ }, "testfile", ct).ConfigureAwait(false);
+
+ Assert.NotNull(file.ErrorInfo);
+ }
+
+ public async Task GetFileInfoTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var path2 = CreateFile(path, "testfile", 1024);
+ var fileNodeId = $"nsu=FileSystem;s=2:{path2}";
+ var fileInfo = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(fileInfo.ErrorInfo);
+ Assert.NotNull(fileInfo.Result);
+ Assert.Equal(1024, fileInfo.Result.Size);
+ Assert.True(fileInfo.Result.Writable);
+ Assert.Equal(0, fileInfo.Result.OpenCount);
+ }
+
+ public async Task GetFileInfoTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var fileNodeId = $"nsu=FileSystem;s=2:{Path.Combine(path, "bad")}";
+ var fileInfo = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fileInfo.ErrorInfo);
+ Assert.Null(fileInfo.Result);
+ }
+
+ public async Task GetFileInfoTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var f = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2);
+ Directory.CreateDirectory(path);
+ CreateFile(path, f, 100);
+ var fileInfo = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{Path.Combine(root, p1)}",
+ BrowsePath = new List { $"nsu=FileSystem;{p2}", $"nsu=FileSystem;{f}" }
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(fileInfo.ErrorInfo);
+ Assert.NotNull(fileInfo.Result);
+ Assert.Equal(100, fileInfo.Result.Size);
+ Assert.True(fileInfo.Result.Writable);
+ Assert.Equal(0, fileInfo.Result.OpenCount);
+ }
+
+ public async Task DeleteFileTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var path2 = CreateFile(path, "testfile", 1024);
+ var fileToDeleteId = $"nsu=FileSystem;s=2:{path2}";
+
+ Assert.NotEmpty(Directory.GetFiles(path));
+
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = fileToDeleteId
+ }, ct: ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(0u, result.StatusCode);
+ Assert.Empty(Directory.GetFiles(path));
+ }
+
+ public async Task DeleteFileTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var path2 = CreateFile(path, "testfile", 1024);
+
+ Assert.NotEmpty(Directory.GetFiles(path));
+
+ var parentDirectoryId = $"nsu=FileSystem;s=1:{path}";
+
+ var fileToDeleteId = $"nsu=FileSystem;s=2:{path2}";
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = fileToDeleteId
+ },
+ new FileSystemObjectModel
+ {
+ NodeId = parentDirectoryId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(0u, result.StatusCode);
+ Assert.Empty(Directory.GetFiles(path));
+ }
+
+ public async Task DeleteFileTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+
+ var parentDirectoryId = $"nsu=FileSystem;s=1:{path}";
+
+ var fileToDeleteId = $"nsu=FileSystem;s=2:{Path.Combine(path, "wrong")}";
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = fileToDeleteId
+ },
+ new FileSystemObjectModel
+ {
+ NodeId = parentDirectoryId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(Opc.Ua.StatusCodes.BadNotFound, result.StatusCode);
+ }
+
+ public async Task DeleteFileTest4Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var f = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2);
+ Directory.CreateDirectory(path);
+ CreateFile(path, f, 100);
+
+ Assert.NotEmpty(Directory.GetFiles(path));
+
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{Path.Combine(root, p1)}",
+ BrowsePath = new List { $"nsu=FileSystem;{p2}", $"nsu=FileSystem;{f}" }
+ }, ct: ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(0u, result.StatusCode);
+ Assert.Empty(Directory.GetFiles(path));
+ }
+
+ public async Task DeleteFileTest5Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var root = _tempPath;
+ var p1 = Path.GetRandomFileName();
+ var p2 = Path.GetRandomFileName();
+ var f = Path.GetRandomFileName();
+
+ var path = Path.Combine(root, p1, p2);
+ Directory.CreateDirectory(path);
+ CreateFile(path, f, 100);
+ Assert.NotEmpty(Directory.GetFiles(path));
+
+ var result = await services.DeleteFileSystemObjectAsync(_connection,
+ new FileSystemObjectModel
+ {
+ NodeId = $"nsu=FileSystem;s=1:{Path.Combine(root, p1)}",
+ BrowsePath = new List { $"nsu=FileSystem;{p2}", "nsu=FileSystem;Notexisting" }
+ }, ct: ct).ConfigureAwait(false);
+
+ Assert.NotNull(result);
+ Assert.Equal(Opc.Ua.StatusCodes.BadNotFound, result.StatusCode);
+ }
+
+ private static string CreateFile(string path, string name, long length)
+ {
+ var fullPath = Path.Combine(path, name);
+ using var f = File.Create(fullPath);
+ var buffer = new byte[length];
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ buffer[i] = (byte)i;
+ }
+ f.Write(buffer);
+ return fullPath;
+ }
+
+ private readonly T _connection;
+ private readonly string _tempPath;
+ private readonly Func> _services;
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/ReadTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/ReadTests.cs
new file mode 100644
index 0000000000..8fa265d362
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/ReadTests.cs
@@ -0,0 +1,293 @@
+// ------------------------------------------------------------
+// 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.Models;
+ using System;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Xunit;
+
+ public class ReadTests
+ {
+ ///
+ /// Create tests
+ ///
+ ///
+ ///
+ ///
+ public ReadTests(Func> services, T connection, string tempPath)
+ {
+ _services = services;
+ _connection = connection;
+ _tempPath = tempPath;
+ }
+
+ public async Task ReadFileTest0Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 1 * 1024 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream = await services.OpenReadAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ var buffer = new byte[256 * 1024];
+ await stream.Result.ReadExactlyAsync(buffer, ct).ConfigureAwait(false);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+
+ await stream.Result.ReadExactlyAsync(buffer, ct).ConfigureAwait(false);
+ }
+ }
+
+ public async Task ReadFileTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream = await services.OpenReadAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(1024, fi.Result.Size);
+ Assert.Equal(1, fi.Result.OpenCount);
+ Assert.False(fi.Result.Writable);
+
+ var buffer = new byte[1024];
+ var read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(read, buffer.Length);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+
+ read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(0, read);
+ }
+ {
+ // Now check it is closed
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ }
+ }
+
+ public async Task ReadFileTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 1 * 1024 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream = await services.OpenReadAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(1 * 1024 * 1024, fi.Result.Size);
+ Assert.Equal(1, fi.Result.OpenCount);
+ Assert.False(fi.Result.Writable);
+
+ var buffer = new byte[256 * 1024];
+ var read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(read, buffer.Length);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+
+ read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(buffer.Length, read);
+ }
+ {
+ // Now check it is closed
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ }
+ }
+
+ public async Task ReadFileTest3Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream = await services.OpenReadAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(1024, fi.Result.Size);
+ Assert.Equal(1, fi.Result.OpenCount);
+ Assert.False(fi.Result.Writable);
+
+ var buffer = new byte[2 * 1024];
+ var read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(1024, read);
+ for (var i = 0; i < read; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+
+ read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(0, read);
+ }
+ {
+ // Now check it is closed
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ }
+ }
+
+ public async Task ReadFileTest4Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream1 = await services.OpenReadAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+ Assert.Null(stream1.ErrorInfo);
+ Assert.NotNull(stream1.Result);
+ await using (var __ = stream1.Result.ConfigureAwait(false))
+ {
+ var stream = await services.OpenReadAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(1024, fi.Result.Size);
+ Assert.Equal(2, fi.Result.OpenCount);
+ Assert.False(fi.Result.Writable);
+
+ var buffer = new byte[2 * 1024];
+ var read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(1024, read);
+ for (var i = 0; i < read; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+
+ read = await stream.Result.ReadAsync(buffer, ct).ConfigureAwait(false);
+ Assert.Equal(0, read);
+ }
+ {
+ // Now check it is closed
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(1, fi.Result.OpenCount);
+ Assert.False(fi.Result.Writable);
+ }
+ }
+ }
+
+ private static string CreateFile(string path, string name, long length)
+ {
+ var fullPath = Path.Combine(path, name);
+ using var f = File.Create(fullPath);
+ var buffer = new byte[length];
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ buffer[i] = (byte)i;
+ }
+ f.Write(buffer);
+ return fullPath;
+ }
+
+ private readonly T _connection;
+ private readonly string _tempPath;
+ private readonly Func> _services;
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/WriteTests.cs b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/WriteTests.cs
new file mode 100644
index 0000000000..31306c9d3d
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher.Testing/tests/Tests/FileSystem/WriteTests.cs
@@ -0,0 +1,348 @@
+// ------------------------------------------------------------
+// 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.Models;
+ using System;
+ using System.IO;
+ using System.Linq;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using Xunit;
+
+ public class WriteTests
+ {
+ ///
+ /// Create tests
+ ///
+ ///
+ ///
+ ///
+ public WriteTests(Func> services, T connection, string tempPath)
+ {
+ _services = services;
+ _connection = connection;
+ _tempPath = tempPath;
+ }
+
+ public async Task WriteFileTest0Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 8 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream = await services.OpenWriteAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, FileWriteMode.Create, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ // Now write file
+ var buffer = Enumerable.Range(0, 1024).Select(b => (byte)b).ToArray();
+ await stream.Result.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+ {
+ var buffer = await File.ReadAllBytesAsync(file, ct).ConfigureAwait(false);
+ Assert.Equal(1024, buffer.Length);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+ }
+ }
+
+ public async Task WriteFileTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 8 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(8 * 1024, fi.Result.Size);
+ Assert.Equal(0, fi.Result.OpenCount);
+
+ var stream = await services.OpenWriteAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, FileWriteMode.Create, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.Size);
+ Assert.Equal(1, fi.Result.OpenCount);
+
+ // Now write file
+ var buffer = Enumerable.Range(0, 1024).Select(b => (byte)b).ToArray();
+ await stream.Result.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+ {
+ // Now check it is closed
+ fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ Assert.Equal(1024, fi.Result.Size);
+
+ var buffer = await File.ReadAllBytesAsync(file, ct).ConfigureAwait(false);
+ Assert.Equal(1024, buffer.Length);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+ }
+ }
+
+ public async Task WriteFileTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 2 * 1024 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(2 * 1024 * 1024, fi.Result.Size);
+ Assert.Equal(0, fi.Result.OpenCount);
+
+ var stream = await services.OpenWriteAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, FileWriteMode.Write, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(2 * 1024 * 1024, fi.Result.Size);
+ Assert.Equal(1, fi.Result.OpenCount);
+
+ // Now write first half of file
+ var buffer = new byte[1 * 1024 * 1024];
+ await stream.Result.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+ {
+ // Now check it is closed
+ fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ Assert.Equal(2 * 1024 * 1024, fi.Result.Size);
+
+ var buffer = await File.ReadAllBytesAsync(file, ct).ConfigureAwait(false);
+ Assert.Equal(2 * 1024 * 1024, buffer.Length);
+ for (var i = 0; i < buffer.Length / 2; i++)
+ {
+ Assert.Equal(0, buffer[i]);
+ }
+ for (var i = buffer.Length / 2; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+ }
+ }
+
+ public async Task AppendFileTest0Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 8 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var stream = await services.OpenWriteAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, FileWriteMode.Append, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ // Now write file
+ var buffer = Enumerable.Range(8 * 1024, 2 * 1024).Select(b => (byte)b).ToArray();
+ await stream.Result.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+ {
+ var buffer = await File.ReadAllBytesAsync(file, ct).ConfigureAwait(false);
+ Assert.Equal(10 * 1024, buffer.Length);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+ }
+ }
+
+ public async Task AppendFileTest1Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = CreateFile(path, "testfile", 8 * 1024);
+
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(8 * 1024, fi.Result.Size);
+ Assert.Equal(0, fi.Result.OpenCount);
+
+ var stream = await services.OpenWriteAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, FileWriteMode.Append, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(8 * 1024, fi.Result.Size);
+ Assert.Equal(1, fi.Result.OpenCount);
+
+ // Now write file
+ var buffer = Enumerable.Range(8 * 1024, 2 * 1024).Select(b => (byte)b).ToArray();
+ await stream.Result.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+ {
+ // Now check it is closed
+ fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ Assert.Equal(10 * 1024, fi.Result.Size);
+
+ var buffer = await File.ReadAllBytesAsync(file, ct).ConfigureAwait(false);
+ Assert.Equal(10 * 1024, buffer.Length);
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ Assert.Equal((byte)i, buffer[i]);
+ }
+ }
+ }
+
+ public async Task AppendFileTest2Async(CancellationToken ct = default)
+ {
+ var services = _services();
+
+ var path = Path.Combine(_tempPath, Path.GetRandomFileName());
+ Directory.CreateDirectory(path);
+ var file = Path.Combine(path, "testfile");
+ var fileNodeId = $"nsu=FileSystem;s=2:{file}";
+ await File.Create(file).DisposeAsync().ConfigureAwait(false);
+
+ for (var i = 0; i < 10; i++)
+ {
+ var stream = await services.OpenWriteAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, FileWriteMode.Append, ct).ConfigureAwait(false);
+
+ Assert.Null(stream.ErrorInfo);
+ Assert.NotNull(stream.Result);
+ await using (var _ = stream.Result.ConfigureAwait(false))
+ {
+ // Now write file
+ var buffer = new byte[130000];
+ Array.Fill(buffer, (byte)i);
+ await stream.Result.WriteAsync(buffer, ct).ConfigureAwait(false);
+ }
+ }
+ {
+ // Now check it is closed
+ var fi = await services.GetFileInfoAsync(_connection, new FileSystemObjectModel
+ {
+ NodeId = fileNodeId
+ }, ct).ConfigureAwait(false);
+
+ Assert.NotNull(fi.Result);
+ Assert.Equal(0, fi.Result.OpenCount);
+ Assert.True(fi.Result.Writable);
+ Assert.Equal(10 * 130000, fi.Result.Size);
+
+ var fs = File.Open(file, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
+ await using var _ = fs.ConfigureAwait(false);
+ for (var i = 0; i < 10; i++)
+ {
+ fs.Seek(i * 130000, SeekOrigin.Begin);
+ Assert.Equal(i, fs.ReadByte());
+ }
+ }
+ }
+
+ private static string CreateFile(string path, string name, long length)
+ {
+ var fullPath = Path.Combine(path, name);
+ using var f = File.Create(fullPath);
+ var buffer = new byte[length];
+ for (var i = 0; i < buffer.Length; i++)
+ {
+ buffer[i] = (byte)i;
+ }
+ f.Write(buffer);
+ return fullPath;
+ }
+
+ private readonly T _connection;
+ private readonly string _tempPath;
+ private readonly Func> _services;
+ }
+}
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 b5a10f799b..b245789604 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
@@ -9,7 +9,7 @@
-
+
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs
index 8bbd19b845..ce6c6c9703 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Extensions/PublishedDataSetSourceModelEx.cs
@@ -340,8 +340,10 @@ internal static IEnumerable ToMonitoredItems(
?? settings?.DefaultSamplingInterval
?? options.DefaultSamplingInterval,
HeartbeatInterval = publishedVariable.HeartbeatInterval
+ ?? settings?.DefaultHeartbeatInterval
?? options.DefaultHeartbeatInterval,
HeartbeatBehavior = publishedVariable.HeartbeatBehavior
+ ?? settings?.DefaultHeartbeatBehavior
?? options.DefaultHeartbeatBehavior,
AggregateFilter = null
};
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs
index c37b2a3513..8aa76a1695 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/NodeServices.cs
@@ -19,21 +19,25 @@ namespace Azure.IIoT.OpcUa.Publisher.Services
using Opc.Ua;
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 services. It uses the OPC ua client interface to access
- /// the server.
+ /// and browse and base foundational services like file transfer services.
+ /// It uses the OPC ua client interface to access the server.
///
///
- public sealed class NodeServices : INodeServices, INodeServicesInternal, IDisposable
+ public sealed class NodeServices : INodeServices,
+ IFileSystemServices, INodeServicesInternal, IDisposable
{
///
/// Create node service
@@ -312,10 +316,10 @@ public async Task GetMetadataAsync(
else if (node.NodeClass is NodeClass.Variable or NodeClass.Object)
{
// Get type definition
- var references = await context.Session.FindTargetOfReferenceAsync(
+ var (references, ei) = await context.Session.FindAsync(
request.Header.ToRequestHeader(_timeProvider), nodeId.YieldReturn(),
- ReferenceTypeIds.HasTypeDefinition, context.Ct).ConfigureAwait(false);
- (_, typeId) = references.FirstOrDefault();
+ ReferenceTypeIds.HasTypeDefinition, maxResults: 1, ct: context.Ct).ConfigureAwait(false);
+ typeId = references.FirstOrDefault(r => r.ErrorInfo == null).Node;
if (NodeId.IsNull(typeId))
{
typeId = nodeId;
@@ -1427,6 +1431,382 @@ 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)
{
@@ -1435,6 +1815,48 @@ public NamespaceFormat GetNamespaceFormat(RequestHeaderModel? header)
?? 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
///
@@ -1616,12 +2038,10 @@ private static async Task ResolveBrowsePathToNodeAsync(
{
rootId = ObjectIds.RootFolder;
}
- var result = new BrowsePathResponseModel
+ var browsepaths = new BrowsePathCollection
{
- Targets = new List()
- };
- var browsepaths = new BrowsePathCollection {
- new BrowsePath {
+ new BrowsePath
+ {
StartingNode = rootId,
RelativePath = paths.ToRelativePath(session.MessageContext)
}
@@ -2059,6 +2479,388 @@ private IEnumerable CollectReferences(
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;
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs
index 0c22062608..538333c315 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/PublisherDiagnosticCollector.cs
@@ -270,6 +270,10 @@ internal WriterGroupDiagnosticModel AggregateModel
{
MonitoredOpcNodesFailedCount = MonitoredOpcNodesFailedCount +
writers.Sum(w => w.MonitoredOpcNodesFailedCount),
+ ActiveConditionCount = ActiveConditionCount +
+ writers.Sum(w => w.ActiveConditionCount),
+ ActiveHeartbeatCount = ActiveHeartbeatCount +
+ writers.Sum(w => w.ActiveHeartbeatCount),
MonitoredOpcNodesSucceededCount = MonitoredOpcNodesSucceededCount +
writers.Sum(w => w.MonitoredOpcNodesSucceededCount),
MonitoredOpcNodesLateCount = MonitoredOpcNodesLateCount +
@@ -337,6 +341,10 @@ public WriterGroupDiagnosticModel Get(string dataSetWriterId, TimeProvider timeP
(d, i) => d.BadPublishRequestsRatio = (double)i,
["iiot_edge_publisher_min_publish_requests_per_subscription"] =
(d, i) => d.MinPublishRequestsRatio = (double)i,
+ ["iiot_edge_publisher_heartbeat_enabled_nodes"] =
+ (d, i) => d.ActiveHeartbeatCount = (long)i,
+ ["iiot_edge_publisher_condition_enabled_nodes"] =
+ (d, i) => d.ActiveConditionCount = (long)i,
["iiot_edge_publisher_unassigned_notification_count"] =
(d, i) => d.IngressUnassignedChanges = (long)i,
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs
index 3ccbdee047..fde9512569 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Services/RuntimeStateReporter.cs
@@ -642,6 +642,10 @@ static string Format(long changes, long lastMinute, double s)
.AppendFormat(CultureInfo.CurrentCulture, "{0,14:0.##}", info.GoodPublishRequestsRatio).Append(" | ")
.AppendFormat(CultureInfo.CurrentCulture, "{0:0.##}", info.BadPublishRequestsRatio)
.AppendLine()
+ .Append(" # Heartbeats/Condition items active : ")
+ .AppendFormat(CultureInfo.CurrentCulture, "{0,14:n0}", info.ActiveHeartbeatCount).Append(" | ")
+ .AppendFormat(CultureInfo.CurrentCulture, "{0:n0}", info.ActiveConditionCount)
+ .AppendLine()
.Append(" # Ingress value changes : ")
.AppendFormat(CultureInfo.CurrentCulture, "{0,14:n0}", info.IngressValueChanges).Append(' ')
.AppendLine(valueChangesPerSecFormatted)
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/FileSystemEx.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/FileSystemEx.cs
new file mode 100644
index 0000000000..d51ff2f290
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/FileSystemEx.cs
@@ -0,0 +1,301 @@
+// ------------------------------------------------------------
+// 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.Extensions
+{
+ using Azure.IIoT.OpcUa.Publisher.Stack;
+ using Azure.IIoT.OpcUa.Publisher.Stack.Models;
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ using Opc.Ua;
+ using Opc.Ua.Extensions;
+ using System;
+ using System.Buffers;
+ using System.Diagnostics;
+ using System.Linq;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ ///
+ /// File system methods
+ ///
+ public static class FileSystemEx
+ {
+ ///
+ /// Get file info
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task<(FileInfoModel?, ServiceResultModel?)> GetFileInfoAsync(
+ this IOpcUaSession session, RequestHeader header, NodeId nodeId,
+ CancellationToken ct = default)
+ {
+ try
+ {
+ var browsePaths = new string[]
+ {
+ BrowseNames.Size,
+ BrowseNames.Writable,
+ BrowseNames.UserWritable,
+ BrowseNames.OpenCount,
+ BrowseNames.MimeType,
+ BrowseNames.MaxByteStringLength,
+ BrowseNames.LastModifiedTime
+ };
+ var response = await session.Services.TranslateBrowsePathsToNodeIdsAsync(
+ header, browsePaths.Select(b => new BrowsePath
+ {
+ StartingNode = nodeId,
+ RelativePath = new RelativePath(b)
+ }).ToArray(), ct).ConfigureAwait(false);
+ Debug.Assert(response != null);
+ var results = response.Validate(response.Results, r => r.StatusCode,
+ response.DiagnosticInfos, browsePaths);
+ if (results.ErrorInfo != null)
+ {
+ return (null, results.ErrorInfo);
+ }
+ if (results.All(r => r.ErrorInfo != null))
+ {
+ return (null, new ServiceResultModel
+ {
+ StatusCode = StatusCodes.BadNotFound,
+ ErrorMessage = "File info not found."
+ });
+ }
+ var read = await session.Services.ReadAsync(header, 0.0,
+ Opc.Ua.TimestampsToReturn.Neither, results
+ .Select(r => r.Result.Targets.Count > 0 ?
+ r.Result.Targets[0].TargetId : ExpandedNodeId.Null)
+ .Select(n => new ReadValueId
+ {
+ AttributeId = Attributes.Value,
+ NodeId = n.ToNodeId(session.MessageContext.NamespaceUris)
+ })
+ .ToArray(), ct).ConfigureAwait(false);
+ var values = read.Validate(read.Results, r => r.StatusCode, read.DiagnosticInfos,
+ browsePaths);
+ if (values.ErrorInfo != null)
+ {
+ return (null, results.ErrorInfo);
+ }
+ return (new FileInfoModel
+ {
+ Size = values[0].Result?.Value as long? ?? 0,
+ //
+ Writable = values[2].Result?.Value as bool? ?? false,
+ OpenCount = values[3].Result?.Value as ushort? ?? 0,
+ MimeType = values[4].Result?.Value as string,
+ MaxBufferSize = values[5].Result?.Value as uint?,
+ LastModified = values[6].Result?.Value as DateTime?
+ }, null);
+ }
+ catch (Exception ex)
+ {
+ return (null, ex.ToServiceResultModel());
+ }
+ }
+
+ ///
+ /// Get buffer size
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task GetBufferSizeAsync(this IOpcUaSession session,
+ RequestHeader header, NodeId nodeId, CancellationToken ct = default)
+ {
+ try
+ {
+ var (fileInfo, errorInfo) = await session.GetFileInfoAsync(
+ header, nodeId, ct).ConfigureAwait(false);
+ var bufferSize = fileInfo?.MaxBufferSize;
+ if (errorInfo == null && fileInfo?.MaxBufferSize != null)
+ {
+ return fileInfo.MaxBufferSize;
+ }
+ var caps = await session.GetServerCapabilitiesAsync(
+ NamespaceFormat.Index, ct).ConfigureAwait(false);
+ return caps.OperationLimits.MaxByteStringLength;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ /// Open file
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task<(uint?, ServiceResultModel?)> OpenAsync(this IOpcUaSession session,
+ RequestHeader header, NodeId nodeId, byte mode, CancellationToken ct)
+ {
+ try
+ {
+ // Call open method
+ var request = new CallMethodRequestCollection
+ {
+ new CallMethodRequest
+ {
+ ObjectId = nodeId,
+ MethodId = MethodIds.FileType_Open,
+ InputArguments = new [] { new Variant(mode) }
+ }
+ };
+ var response = await session.Services.CallAsync(header, request, ct).ConfigureAwait(false);
+ var results = response.Validate(response.Results, r => r.StatusCode,
+ response.DiagnosticInfos, request);
+ if (results.ErrorInfo != null)
+ {
+ return (null, 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 uint fileHandle)
+ {
+ return (null, results[0].ErrorInfo ?? new ServiceResultModel
+ {
+ ErrorMessage = "no file handle returned"
+ });
+ }
+ return (fileHandle, null);
+ }
+ catch (Exception ex)
+ {
+ return (null, ex.ToServiceResultModel());
+ }
+ }
+
+ ///
+ /// Write buffer at current position in file
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task WriteAsync(this IOpcUaSession session,
+ RequestHeader header, NodeId nodeId, uint fileHandle, byte[] buffer,
+ CancellationToken ct)
+ {
+ try
+ {
+ // Call write method
+ var request = new CallMethodRequestCollection
+ {
+ new CallMethodRequest
+ {
+ ObjectId = nodeId,
+ MethodId = MethodIds.FileType_Write,
+ InputArguments = new [] { new Variant(fileHandle), new Variant(buffer) }
+ }
+ };
+ var response = await session.Services.CallAsync(header, request, ct).ConfigureAwait(false);
+ var results = response.Validate(response.Results, r => r.StatusCode,
+ response.DiagnosticInfos, request);
+ return results.ErrorInfo;
+ }
+ catch (Exception ex)
+ {
+ return ex.ToServiceResultModel();
+ }
+ }
+
+ ///
+ /// Read from current position in file
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task<(byte[]?, ServiceResultModel?)> ReadAsync(this IOpcUaSession session,
+ RequestHeader header, NodeId nodeId, uint fileHandle, int length,
+ CancellationToken ct)
+ {
+ try
+ {
+ // Call write method
+ var request = new CallMethodRequestCollection
+ {
+ new CallMethodRequest
+ {
+ ObjectId = nodeId,
+ MethodId = MethodIds.FileType_Read,
+ InputArguments = new [] { new Variant(fileHandle), new Variant(length) }
+ }
+ };
+ var response = await session.Services.CallAsync(header, request, ct).ConfigureAwait(false);
+ var results = response.Validate(response.Results, r => r.StatusCode,
+ response.DiagnosticInfos, request);
+ if (results.ErrorInfo != null)
+ {
+ return (null, results.ErrorInfo);
+ }
+ if (results[0].Result?.OutputArguments == null ||
+ results[0].Result.OutputArguments.Count == 0 ||
+ results[0].Result.OutputArguments[0].Value is not byte[] byteString)
+ {
+ byteString = Array.Empty();
+ }
+ return (byteString, null);
+ }
+ catch (Exception ex)
+ {
+ return (null, ex.ToServiceResultModel());
+ }
+ }
+
+ ///
+ /// Close file handle of file
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task CloseAsync(this IOpcUaSession session,
+ RequestHeader header, NodeId nodeId, uint fileHandle, CancellationToken ct)
+ {
+ // Call open method
+ try
+ {
+ var request = new CallMethodRequestCollection
+ {
+ new CallMethodRequest
+ {
+ ObjectId = nodeId,
+ MethodId = MethodIds.FileType_Close,
+ InputArguments = new [] { new Variant(fileHandle) }
+ }
+ };
+ var response = await session.Services.CallAsync(header, request, ct).ConfigureAwait(false);
+ var results = response.Validate(response.Results, r => r.StatusCode,
+ response.DiagnosticInfos, request);
+ return results.ErrorInfo;
+ }
+ catch (Exception ex)
+ {
+ return ex.ToServiceResultModel();
+ }
+ }
+ }
+}
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 3b9497949f..7acb4a8156 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ServiceResultEx.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/ServiceResultEx.cs
@@ -5,6 +5,7 @@
namespace Azure.IIoT.OpcUa.Publisher.Models
{
+ using Furly.Exceptions;
using Opc.Ua;
using System;
@@ -18,7 +19,7 @@ internal static class ServiceResultEx
///
///
///
- public static ServiceResultModel? ToServiceResultModel(this ServiceResult sr)
+ public static ServiceResultModel ToServiceResultModel(this ServiceResult sr)
{
return new ServiceResultModel
{
@@ -39,25 +40,32 @@ internal static class ServiceResultEx
///
///
///
- public static ServiceResultModel? ToServiceResultModel(this Exception e)
+ public static ServiceResultModel ToServiceResultModel(this Exception e)
{
switch (e)
{
- case null:
- return null;
case ServiceResultException sre:
return sre.Result.ToServiceResultModel();
case TimeoutException:
+ return Create(StatusCodes.BadTimeout, e.Message);
case OperationCanceledException:
- return null;
+ return Create(StatusCodes.BadRequestCancelledByClient, e.Message);
+ case ResourceInvalidStateException:
+ return Create(StatusCodes.BadInvalidState, e.Message);
+ case ResourceNotFoundException:
+ return Create(StatusCodes.BadNotFound, e.Message);
+ case ResourceConflictException:
+ return Create(StatusCodes.BadDuplicateReferenceNotAllowed, e.Message);
default:
- return new ServiceResultModel
- {
- ErrorMessage = e.Message,
- SymbolicId = StatusCode.LookupSymbolicId(StatusCodes.Bad),
- StatusCode = StatusCodes.Bad
- };
+ return Create(StatusCodes.Bad, e.Message);
}
+ static ServiceResultModel Create(uint code, string message) =>
+ new ServiceResultModel
+ {
+ ErrorMessage = message,
+ SymbolicId = StatusCode.LookupSymbolicId(code),
+ StatusCode = 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 f4eae0ba45..05dd57a2ff 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/SessionEx.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Extensions/SessionEx.cs
@@ -35,7 +35,7 @@ public static class SessionEx
///
///
///
- public static async Task<(T?, ServiceResultModel?)> ReadAttributeAsync(
+ internal static async Task<(T?, ServiceResultModel?)> ReadAttributeAsync(
this IOpcUaSession session, RequestHeader header, NodeId nodeIds,
uint attributeId, CancellationToken ct = default)
{
@@ -54,7 +54,7 @@ public static class SessionEx
///
///
///
- public static async Task> ReadAttributeAsync(
+ internal static async Task> ReadAttributeAsync(
this IOpcUaSession session, RequestHeader header, IEnumerable nodeIds,
uint attributeId, CancellationToken ct = default)
{
@@ -625,7 +625,7 @@ internal static async Task> ReadNodeAttributesA
///
///
///
- public static async Task CollectTypeHierarchyAsync(this IOpcUaSession session,
+ internal static async Task CollectTypeHierarchyAsync(this IOpcUaSession session,
RequestHeader header, NodeId typeId, IList<(NodeId, ReferenceDescription)> hierarchy,
CancellationToken ct = default)
{
@@ -686,8 +686,10 @@ public static async Task CollectTypeHierarchyAsync(this IOpcUaSession session,
CancellationToken ct = default)
{
// find the children of the type.
- var nodeToBrowse = new BrowseDescriptionCollection {
- new BrowseDescription {
+ var nodeToBrowse = new BrowseDescriptionCollection
+ {
+ new BrowseDescription
+ {
NodeId = parent == null ? typeId : parent.NodeId.ToNodeId(session.MessageContext),
BrowseDirection = Opc.Ua.BrowseDirection.Forward,
ReferenceTypeId = ReferenceTypeIds.HasChild,
@@ -712,11 +714,19 @@ public static async Task CollectTypeHierarchyAsync(this IOpcUaSession session,
var references = result.References
.Where(r => !r.NodeId.IsAbsolute)
.ToList();
+ if (references.Count == 0)
+ {
+ continue;
+ }
// find the modelling rules.
- var targets = await session.FindTargetOfReferenceAsync(requestHeader,
+ var (targets, errorInfo2) = await session.FindAsync(requestHeader,
references.Select(r => (NodeId)r.NodeId),
- ReferenceTypeIds.HasModellingRule, ct).ConfigureAwait(false);
+ ReferenceTypeIds.HasModellingRule, maxResults: 1, ct: ct).ConfigureAwait(false);
+ if (errorInfo2 != null)
+ {
+ return errorInfo2;
+ }
var referencesWithRules = targets
.Zip(references)
.ToList();
@@ -730,7 +740,7 @@ public static async Task CollectTypeHierarchyAsync(this IOpcUaSession session,
var relativePath = ImmutableRelativePath.Create(parent?.BrowsePath,
"/" + browseName);
var nodeClass = reference.NodeClass.ToServiceType();
- if (NodeId.IsNull(modellingRule.Item2) || nodeClass == null)
+ if (NodeId.IsNull(modellingRule.Node) || nodeClass == null)
{
// if the modelling rule is null then the instance is not part
// of the type declaration.
@@ -756,9 +766,9 @@ public static async Task CollectTypeHierarchyAsync(this IOpcUaSession session,
displayName : $"{parent.DisplayPath}/{displayName}",
DisplayName = displayName,
BrowsePath = relativePath.Path,
- ModellingRule = modellingRule.Item1.AsString(session.MessageContext,
+ ModellingRule = modellingRule.Name.AsString(session.MessageContext,
namespaceFormat),
- ModellingRuleId = modellingRule.Item2.AsString(session.MessageContext,
+ ModellingRuleId = modellingRule.Node.AsString(session.MessageContext,
namespaceFormat),
OverriddenDeclaration = overriden
};
@@ -1206,6 +1216,16 @@ await session.CollectInstanceDeclarationsAsync(requestHeader,
return (nodeModel, results.ErrorInfo ?? lookup[Attributes.NodeClass].Item2);
}
+ ///
+ /// Find results
+ ///
+ ///
+ ///
+ ///
+ ///
+ internal record struct FindResult(QualifiedName Name, NodeId Node,
+ ExpandedNodeId TypeDefinition, ServiceResultModel? ErrorInfo = null);
+
///
/// Finds the targets for the specified reference.
///
@@ -1213,11 +1233,16 @@ await session.CollectInstanceDeclarationsAsync(requestHeader,
///
///
///
+ ///
+ ///
+ ///
+ ///
///
///
- internal static async Task> FindTargetOfReferenceAsync(
+ internal static async Task<(IReadOnlyList, ServiceResultModel?)> FindAsync(
this IOpcUaSession session, RequestHeader requestHeader,
- IEnumerable nodeIds, NodeId referenceTypeId,
+ IEnumerable nodeIds, NodeId referenceTypeId, bool includeSubTypes = false,
+ bool isInverse = false, uint nodeClassMask = 0, uint? maxResults = null,
CancellationToken ct = default)
{
// construct browse request.
@@ -1225,59 +1250,115 @@ await session.CollectInstanceDeclarationsAsync(requestHeader,
.Select(nodeId => new BrowseDescription
{
NodeId = nodeId,
- BrowseDirection = Opc.Ua.BrowseDirection.Forward,
+ BrowseDirection = isInverse ?
+ Opc.Ua.BrowseDirection.Inverse : Opc.Ua.BrowseDirection.Forward,
ReferenceTypeId = referenceTypeId,
- IncludeSubtypes = false,
- NodeClassMask = 0,
- ResultMask = (uint)BrowseResultMask.BrowseName
+ IncludeSubtypes = includeSubTypes,
+ NodeClassMask = nodeClassMask,
+ ResultMask =
+ (uint)BrowseResultMask.BrowseName |
+ (uint)BrowseResultMask.TypeDefinition
}));
- var response = await session.Services.BrowseAsync(requestHeader, null, 1,
- nodesToBrowse, ct).ConfigureAwait(false);
- var results = response.Validate(response.Results, s => s.StatusCode,
- response.DiagnosticInfos, nodesToBrowse);
- var targetIds = new List<(QualifiedName, NodeId)>();
- if (results.ErrorInfo != null)
- {
- return targetIds;
- }
-
var continuationPoints = new ByteStringCollection();
- foreach (var result in results)
+ try
{
- // check for error.
- if (result.ErrorInfo != null)
+ var response = await session.Services.BrowseAsync(requestHeader, null,
+ maxResults ?? 0u, nodesToBrowse, ct).ConfigureAwait(false);
+ var results = response.Validate(response.Results, s => s.StatusCode,
+ response.DiagnosticInfos, nodesToBrowse);
+ var targetIds = new List();
+ if (results.ErrorInfo != null)
{
- targetIds.Add((QualifiedName.Null, NodeId.Null));
- continue;
+ return (targetIds, results.ErrorInfo);
}
- // check for continuation point.
- if (result.Result.ContinuationPoint?.Length > 0)
+
+ foreach (var result in results)
{
- continuationPoints.Add(result.Result.ContinuationPoint);
+ // check for error.
+ if (result.ErrorInfo != null)
+ {
+ targetIds.Add(new FindResult(QualifiedName.Null, NodeId.Null,
+ ExpandedNodeId.Null, result.ErrorInfo));
+ continue;
+ }
+ // check for continuation point.
+ if (result.Result.ContinuationPoint?.Length > 0)
+ {
+ continuationPoints.Add(result.Result.ContinuationPoint);
+ }
+ if (!Extract(targetIds, result.Result.References))
+ {
+ break;
+ }
}
- // get the node id.
- if (result.Result.References.Count > 0)
+
+ while (continuationPoints.Count > 0 && !maxResults.HasValue)
{
- if (NodeId.IsNull(result.Result.References[0].NodeId) ||
- result.Result.References[0].NodeId.IsAbsolute)
+ var next = await session.Services.BrowseNextAsync(requestHeader, false,
+ continuationPoints, ct).ConfigureAwait(false);
+ var nextResults = next.Validate(next.Results, s => s.StatusCode,
+ next.DiagnosticInfos, continuationPoints);
+ if (nextResults.ErrorInfo != null)
{
- targetIds.Add((QualifiedName.Null, NodeId.Null));
- continue;
+ return (targetIds, nextResults.ErrorInfo);
+ }
+
+ continuationPoints = new ByteStringCollection();
+ foreach (var result in nextResults)
+ {
+ // check for error.
+ if (result.ErrorInfo != null)
+ {
+ targetIds.Add(new FindResult(QualifiedName.Null, NodeId.Null,
+ ExpandedNodeId.Null, result.ErrorInfo));
+ continue;
+ }
+ // check for continuation point.
+ if (result.Result.ContinuationPoint?.Length > 0)
+ {
+ continuationPoints.Add(result.Result.ContinuationPoint);
+ }
+ if (!Extract(targetIds, result.Result.References))
+ {
+ break;
+ }
}
- targetIds.Add(
- (result.Result.References[0].BrowseName,
- (NodeId)result.Result.References[0].NodeId));
+ }
+ return (targetIds, null);
+ }
+ finally
+ {
+ // release continuation points.
+ if (continuationPoints.Count > 0)
+ {
+ try
+ {
+ await session.Services.BrowseNextAsync(requestHeader, true,
+ continuationPoints, ct).ConfigureAwait(false);
+ }
+ catch { }
}
}
- // release continuation points.
- if (continuationPoints.Count > 0)
+ static bool Extract(List targetIds, ReferenceDescriptionCollection references)
{
- await session.Services.BrowseNextAsync(requestHeader, true,
- continuationPoints, ct).ConfigureAwait(false);
+ // get the node ids.
+ foreach (var reference in references)
+ {
+ if (NodeId.IsNull(reference.NodeId) ||
+ reference.NodeId.IsAbsolute)
+ {
+ targetIds.Add(new FindResult(QualifiedName.Null, NodeId.Null, ExpandedNodeId.Null,
+ new ServiceResultModel { ErrorMessage = "Target node is null or absolute" }));
+ continue;
+ }
+ targetIds.Add(new FindResult(reference.BrowseName,
+ (NodeId)reference.NodeId,
+ reference.TypeDefinition));
+ }
+ return true;
}
- return targetIds;
}
///
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs
index 93d35d4f65..7bd60621dc 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/IOpcUaClientManager.cs
@@ -40,6 +40,16 @@ public interface IOpcUaClientManager
///
event EventHandler OnConnectionStateChange;
+ ///
+ /// Acquire a session which will be usable until disposed.
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task AcquireSessionAsync(T connection,
+ RequestHeaderModel? header = null, CancellationToken ct = default);
+
///
/// Execute the service on the provided session and
/// return the result.
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISessionHandle.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISessionHandle.cs
new file mode 100644
index 0000000000..fd7307a515
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/ISessionHandle.cs
@@ -0,0 +1,25 @@
+// ------------------------------------------------------------
+// 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 System;
+
+ ///
+ /// The session handle
+ ///
+ public interface ISessionHandle : IDisposable
+ {
+ ///
+ /// Session
+ ///
+ public IOpcUaSession Session { get; }
+
+ ///
+ /// Service call timeout
+ ///
+ TimeSpan ServiceCallTimeout { get; }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/ServiceCallContext.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/ServiceCallContext.cs
index f969ffc5b1..0a80e22a00 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/ServiceCallContext.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Models/ServiceCallContext.cs
@@ -5,18 +5,22 @@
namespace Azure.IIoT.OpcUa.Publisher.Stack.Models
{
+ using Azure.IIoT.OpcUa.Publisher.Stack.Services;
+ using System;
+ using System.Diagnostics;
using System.Threading;
///
- /// Context for a service call invocation
+ /// Context for service call invocations
///
- public sealed record class ServiceCallContext
+ public sealed record class ServiceCallContext : ISessionHandle
{
- ///
- /// The session
- ///
+ ///
public IOpcUaSession Session { get; }
+ ///
+ public TimeSpan ServiceCallTimeout { get; }
+
///
/// A continuation token to track after
/// returning from the call.
@@ -38,12 +42,49 @@ public sealed record class ServiceCallContext
/// Create context
///
///
+ ///
///
- internal ServiceCallContext(
- IOpcUaSession session, CancellationToken ct)
+ internal ServiceCallContext(IOpcUaSession session,
+ TimeSpan serviceCallTimeout, CancellationToken ct = default)
{
Session = session;
+ ServiceCallTimeout = serviceCallTimeout;
Ct = ct;
}
+
+ ///
+ /// Create context
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ internal ServiceCallContext(IOpcUaSession session,
+ TimeSpan serviceCallTimeout, OpcUaClient client,
+ IDisposable sessionLock, CancellationToken ct = default)
+ : this(session, serviceCallTimeout, ct)
+ {
+ client.AddRef();
+
+ _client = client;
+ _sessionLock = sessionLock;
+ // TODO: we could timeout and dispose to catch leaks
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_client != null)
+ {
+ Debug.Assert(_sessionLock != null);
+ _sessionLock.Dispose();
+ _client.Release();
+ _client = null;
+ }
+ }
+
+ private OpcUaClient? _client;
+ private readonly IDisposable? _sessionLock;
}
}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs
index 2a3b9ea3f5..5b2ea2c43c 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Runtime/OpcUaClientConfig.cs
@@ -114,7 +114,7 @@ public sealed class OpcUaClientConfig : PostConfigureOptionBase
/// OPC UA Client based on official ua client reference sample.
@@ -443,6 +443,11 @@ internal async ValueTask CloseAsync()
_lastState = EndpointConnectivityState.Disconnected;
+ if (_diagnosticsDumper != null)
+ {
+ await _diagnosticsDumper.ConfigureAwait(false);
+ }
+
_logger.LogInformation("{Client}: Successfully closed.", this);
}
catch (Exception ex)
@@ -451,10 +456,71 @@ internal async ValueTask CloseAsync()
}
finally
{
+ _channelMonitor.Dispose();
_cts.Dispose();
}
}
+ ///
+ /// Acquire a session
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ internal async Task AcquireAsync(int? connectTimeout,
+ int? serviceCallTimeout, CancellationToken cancellationToken)
+ {
+ var timeout = GetConnectCallTimeout(connectTimeout, serviceCallTimeout);
+ using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ var ct = cts.Token;
+ cts.CancelAfter(timeout); // wait max timeout on the reader lock/session
+ while (true)
+ {
+ if (_disposed)
+ {
+ throw new ConnectionException($"Session {_sessionName} was closed.");
+ }
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var readerlock = await _lock.ReaderLockAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (_session != null)
+ {
+ if (!DisableComplexTypeLoading && !_session.IsTypeSystemLoaded)
+ {
+ // Ensure type system is loaded
+ cts.CancelAfter(timeout);
+ await _session.GetComplexTypeSystemAsync(ct).ConfigureAwait(false);
+ }
+
+ //
+ // Now clients can continue the operation with the session handle
+ // which encapsulates the release of the reader lock as well as
+ // the ref count to the client.
+ //
+ var sessionLock = readerlock;
+ readerlock = null; // Do not dispose below but when handle is disposed
+ return new ServiceCallContext(_session, GetServiceCallTimeout(
+ serviceCallTimeout), this, sessionLock, cancellationToken);
+ }
+ }
+ finally
+ {
+ readerlock?.Dispose();
+ }
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ throw new TimeoutException("Connecting to the endpoint timed out.");
+ }
+ }
+ }
+
///
/// Safely invoke the service call and retry if the session
/// disconnected during call.
@@ -493,8 +559,9 @@ internal async Task RunAsync(Func> service,
await _session.GetComplexTypeSystemAsync(ct).ConfigureAwait(false);
}
- var context = new ServiceCallContext(_session, ct);
- cts.CancelAfter(GetServiceCallTimeout(serviceCallTimeout));
+ var serviceTimeout = GetServiceCallTimeout(serviceCallTimeout);
+ using var context = new ServiceCallContext(_session, serviceTimeout, ct: ct);
+ cts.CancelAfter(serviceTimeout);
var result = await service(context).ConfigureAwait(false);
//
@@ -580,8 +647,9 @@ internal async IAsyncEnumerable RunAsync(
await _session.GetComplexTypeSystemAsync(ct).ConfigureAwait(false);
}
- var context = new ServiceCallContext(_session, ct);
- cts.CancelAfter(GetServiceCallTimeout(serviceCallTimeout));
+ var serviceTimeout = GetServiceCallTimeout(serviceCallTimeout);
+ using var context = new ServiceCallContext(_session, serviceTimeout, ct: ct);
+ cts.CancelAfter(serviceTimeout);
results = await stack.Peek()(context).ConfigureAwait(false);
// Success
@@ -1072,6 +1140,7 @@ await Task.WhenAll(subscriptions.Concat(extra).Select(async subscription =>
}
})).ConfigureAwait(false);
+ session.UpdateOperationTimeout(false);
UpdatePublishRequestCounts();
if (numberOfSubscriptions > 1)
@@ -1710,6 +1779,7 @@ async ValueTask DisposeAsync(OpcUaSession session)
{
try
{
+ session.UpdateOperationTimeout(true);
await session.CloseAsync(CancellationToken.None).ConfigureAwait(false);
_logger.LogDebug("{Client}: Successfully closed session {Session}.",
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 d1a0369b64..a26d47dd52 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaClientManager.cs
@@ -324,6 +324,20 @@ async IAsyncEnumerable ExecuteAsyncCore(
}
}
+ ///
+ public async Task AcquireSessionAsync(ConnectionModel connection,
+ RequestHeaderModel? header, CancellationToken ct)
+ {
+ connection = UpdateConnectionFromHeader(connection, header);
+ if (string.IsNullOrEmpty(connection.Endpoint?.Url))
+ {
+ throw new ArgumentException("Missing endpoint url", nameof(connection));
+ }
+ using var client = GetOrAddClient(connection);
+ return await client.AcquireAsync(header?.ConnectTimeout,
+ header?.ServiceCallTimeout, ct).ConfigureAwait(false);
+ }
+
///
public void 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 8f7a0a9b26..e99d3a6232 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
@@ -31,6 +31,8 @@ internal abstract partial class OpcUaMonitoredItem
[KnownType(typeof(AggregateFilter))]
internal class Condition : Event
{
+ public bool TimerEnabled => _conditionTimer?.Enabled ?? false;
+
///
/// Create condition item
///
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 0be7cb4d76..e90de59e49 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
@@ -39,6 +39,11 @@ internal abstract partial class OpcUaMonitoredItem
[KnownType(typeof(AggregateFilter))]
internal sealed class Heartbeat : DataChange
{
+ ///
+ /// Whether timer is enabled
+ ///
+ public bool TimerEnabled { get; set; }
+
///
/// Create data item with heartbeat
///
@@ -68,7 +73,7 @@ private Heartbeat(Heartbeat item, bool copyEventHandlers,
_heartbeatInterval = item._heartbeatInterval;
_heartbeatBehavior = item._heartbeatBehavior;
_callback = item._callback;
- if (item._timerEnabled)
+ if (item.TimerEnabled)
{
EnableHeartbeatTimer();
}
@@ -212,6 +217,7 @@ public override bool TryCompleteChanges(Subscription subscription,
public override bool TryGetMonitoredItemNotifications(uint sequenceNumber, DateTimeOffset publishTime,
IEncodeable evt, IList notifications)
{
+ _lastSequenceNumber = sequenceNumber;
if (!_disposed && (_heartbeatBehavior & HeartbeatBehavior.PeriodicLKV) == 0)
{
EnableHeartbeatTimer();
@@ -281,6 +287,7 @@ private void SendHeartbeatNotifications(object? sender, ElapsedEventArgs e)
return;
}
+ var lastSequenceNumber = _lastSequenceNumber;
var lastNotification = LastReceivedValue as MonitoredItemNotification;
if ((_heartbeatBehavior & HeartbeatBehavior.WatchdogLKG)
== HeartbeatBehavior.WatchdogLKG &&
@@ -331,8 +338,13 @@ private void SendHeartbeatNotifications(object? sender, ElapsedEventArgs e)
PathFromRoot = TheResolvedRelativePath,
Value = lastValue,
Flags = MonitoredItemSourceFlags.Heartbeat,
- SequenceNumber = 0
+ SequenceNumber = lastSequenceNumber
};
+ if (lastSequenceNumber != _lastSequenceNumber)
+ {
+ // New value came in while running the timer callback - no need to send heartbeat
+ return;
+ }
callback(MessageType.DeltaFrame, heartbeat.YieldReturn(),
diagnosticsOnly: (_heartbeatBehavior & HeartbeatBehavior.WatchdogLKVDiagnosticsOnly)
== HeartbeatBehavior.WatchdogLKVDiagnosticsOnly);
@@ -354,17 +366,11 @@ private void EnableHeartbeatTimer()
_heartbeatTimer = new TimerEx(TimeProvider);
_heartbeatTimer.AutoReset = true;
_heartbeatTimer.Elapsed += SendHeartbeatNotifications;
- _heartbeatTimer.Enabled = true;
- _heartbeatTimer.Interval = _heartbeatInterval;
_logger.LogDebug("Re-enable heartbeat timer");
}
- else if (_heartbeatInterval != _heartbeatTimer.Interval)
- {
- Debug.Assert(_heartbeatTimer.Enabled);
- _heartbeatTimer.Interval = _heartbeatInterval;
- _logger.LogDebug("Re-configured heartbeat timer");
- }
- _timerEnabled = true;
+ _heartbeatTimer.Interval = _heartbeatInterval;
+ _heartbeatTimer.Enabled = true;
+ TimerEnabled = true;
}
}
@@ -382,16 +388,16 @@ private void DisableHeartbeatTimer()
_heartbeatTimer = null;
_logger.LogDebug("Disabled heartbeat timer");
}
- _timerEnabled = false;
+ TimerEnabled = false;
}
}
private TimerEx? _heartbeatTimer;
private HeartbeatBehavior _heartbeatBehavior;
- private bool _timerEnabled;
private TimeSpan _heartbeatInterval;
private Callback? _callback;
private StatusCode? _lastStatusCode;
+ private uint _lastSequenceNumber;
private readonly object _timerLock = new();
private bool _disposed;
}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs
index eeccb03e26..0977a45445 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaMonitoredItem.cs
@@ -946,7 +946,7 @@ protected bool UpdateQueueSize(Subscription subscription, BaseMonitoredItemModel
if (samplingInterval > 0)
{
queueSize = Math.Max(queueSize, (uint)Math.Ceiling(
- (double)publishingInterval / SamplingInterval));
+ (double)publishingInterval / SamplingInterval)) + 1;
if (queueSize != QueueSize && item.QueueSize != queueSize)
{
_logger.LogDebug("Auto-set queue size for {Item} to '{QueueSize}'.",
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 398b1fbeb4..b474bd44d8 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,7 @@ namespace Azure.IIoT.OpcUa.Publisher.Stack.Services
using System.Security.Cryptography.X509Certificates;
using System.Threading;
using System.Threading.Tasks;
- using System.Reflection;
+ using Microsoft.Azure.Devices.Shared;
///
/// OPC UA session extends the SDK session
@@ -723,6 +723,7 @@ private void Initialize()
KeepAliveInterval = keepAliveInterval;
OperationTimeout = operationTimeout;
+ _defaultOperationTimeout = operationTimeout;
}
///
@@ -793,7 +794,7 @@ private void Initialize()
{
_logger.LogError("Failed to find diagnostics for this session ({Error}).",
response.Results[0].StatusCode);
- return null;
+ return null;
}
List? subscriptions = null;
@@ -1235,6 +1236,41 @@ private void Initialize()
};
}
+ ///
+ /// Set appropriate operation timeouts
+ ///
+ ///
+ internal void UpdateOperationTimeout(bool closing)
+ {
+ //
+ // The OperationTimeout while publishing should be twice the
+ // value for PublishingInterval * KeepAliveCount
+ //
+ if (closing)
+ {
+ OperationTimeout = 2000; // Update to 2 seconds for closing
+ return;
+ }
+ var timeout = Subscriptions
+ .Select(s => s.CurrentPublishingInterval * s.CurrentKeepAliveCount)
+ .DefaultIfEmpty(0)
+ .Max() * 2;
+ if (timeout < _defaultOperationTimeout)
+ {
+ timeout = _defaultOperationTimeout;
+ }
+ if (timeout > kMaxOperationTimeout.TotalMilliseconds)
+ {
+ timeout = kMaxOperationTimeout.TotalMilliseconds;
+ }
+ if (OperationTimeout != timeout)
+ {
+ OperationTimeout = (int)timeout;
+ _logger.LogInformation("Operation timeout updated to {Timeout}.",
+ TimeSpan.FromMilliseconds(timeout));
+ }
+ }
+
///
/// Load complex type system
///
@@ -1393,6 +1429,7 @@ private sealed record class LogScope(string name, Stopwatch sw, ILogger logger);
private Task? _complexTypeSystem;
private bool _disposed;
private bool? _diagnosticsEnabled;
+ private int _defaultOperationTimeout;
private readonly CancellationTokenSource _cts = new();
private readonly ILogger _logger;
private readonly OpcUaClient _client;
@@ -1401,5 +1438,6 @@ private sealed record class LogScope(string name, Stopwatch sw, ILogger logger);
private readonly ActivitySource _activitySource = Diagnostics.NewActivitySource();
private static readonly TimeSpan kDefaultOperationTimeout = TimeSpan.FromMinutes(1);
private static readonly TimeSpan kDefaultKeepAliveInterval = TimeSpan.FromSeconds(30);
+ private static readonly TimeSpan kMaxOperationTimeout = TimeSpan.FromMinutes(30);
}
}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs
index 931463c999..0e4b516043 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Stack/Services/OpcUaSubscription.cs
@@ -539,6 +539,7 @@ private async Task CloseCurrentSubscriptionAsync()
var items = CurrentlyMonitored.ToList();
_additionallyMonitored = FrozenDictionary.Empty;
+ RemoveItems(MonitoredItems);
_currentSequenceNumber = 0;
_goodMonitoredItems = 0;
_badMonitoredItems = 0;
@@ -563,10 +564,9 @@ private async Task CloseCurrentSubscriptionAsync()
if (Session != null)
{
await Session.RemoveSubscriptionAsync(this).ConfigureAwait(false);
- Debug.Assert(Session == null);
}
-
- Debug.Assert(!CurrentlyMonitored.Any());
+ Debug.Assert(Session == null, "Subscription should not be part of session");
+ Debug.Assert(!CurrentlyMonitored.Any(), "Not all items removed.");
_logger.LogInformation("Subscription '{Subscription}' closed.", this);
}
catch (Exception e)
@@ -1075,18 +1075,30 @@ private async Task SynchronizeMonitoredItemsAsync(
.Count(r => r.Status?.MonitoringMode == Opc.Ua.MonitoringMode.Sampling);
_notAppliedItems = set
.Count(r => r.Status?.MonitoringMode != r.MonitoringMode);
+ _heartbeatItems = set
+ .Count(r => r is OpcUaMonitoredItem.Heartbeat);
+ _conditionItems = set
+ .Count(r => r is OpcUaMonitoredItem.Condition);
+ var heartbeatsEnabled = set
+ .Count(r => r is OpcUaMonitoredItem.Heartbeat h && h.TimerEnabled);
+ var conditionsEnabled = set
+ .Count(r => r is OpcUaMonitoredItem.Condition h && h.TimerEnabled);
_logger.LogInformation(@"{Subscription} - Now monitoring {Count} nodes:
-# Good/Bad: {Good}/{Bad}
-# Reporting: {Reporting}
-# Sampling: {Sampling}
-# Disabled: {Disabled}
-# Not applied: {NotApplied}
-# Removed: {Disposed}",
+# Good/Bad: {Good}/{Bad}
+# Reporting: {Reporting}
+# Sampling: {Sampling}
+# Heartbeat/ing: {Heartbeat}/{EnabledHeartbeats}
+# Condition/ing: {Conditions}/{EnabledConditions}
+# Disabled: {Disabled}
+# Not applied: {NotApplied}
+# Removed: {Disposed}",
this, set.Count,
_goodMonitoredItems, _badMonitoredItems,
_reportingItems,
_samplingItems,
+ _heartbeatItems, heartbeatsEnabled,
+ _conditionItems, conditionsEnabled,
_disabledItems,
_notAppliedItems,
dispose.Count);
@@ -1188,7 +1200,7 @@ private async ValueTask SyncWithSessionInternalAsync(ISession session,
{
_forceRecreate = false;
_logger.LogInformation(
- "Closing subscription {Subscription} and then re-creating...", this);
+ "======== Closing subscription {Subscription} and re-creating =========", this);
// Does not throw
await CloseCurrentSubscriptionAsync().ConfigureAwait(false);
Debug.Assert(Session == null);
@@ -2043,8 +2055,21 @@ private void OnMonitoredItemWatchdog(object? state)
var msg = $"Performed watchdog action {action} for subscription {this} " +
$"because it has {_lateMonitoredItems} late monitored items.";
+ RunWatchdogAction(action, msg);
+ }
+
+ ///
+ /// Run watchdog action
+ ///
+ ///
+ ///
+ private void RunWatchdogAction(SubscriptionWatchdogBehavior action, string msg)
+ {
switch (action)
{
+ case SubscriptionWatchdogBehavior.Diagnostic:
+ _logger.LogCritical("{Message}", msg);
+ break;
case SubscriptionWatchdogBehavior.Reset:
ResetMonitoredItemWatchdogTimer(false);
_forceRecreate = true;
@@ -2086,13 +2111,13 @@ private void OnKeepAliveMissing(object? state)
if (_continuouslyMissingKeepAlives == CurrentLifetimeCount + 1)
{
+ var action = _template.Configuration?.WatchdogBehavior ?? SubscriptionWatchdogBehavior.Reset;
_logger.LogCritical(
- "#{Count}/{Lifetimecount}: Keep alive count exceeded. Resetting {Subscription}...",
- _continuouslyMissingKeepAlives, CurrentLifetimeCount, this);
+ "#{Count}/{Lifetimecount}: Keep alive count exceeded. Perform {Action} for {Subscription}...",
+ _continuouslyMissingKeepAlives, CurrentLifetimeCount, action, this);
- // TODO: option to fail fast here
- _forceRecreate = true;
- OnSubscriptionManagementTriggered(this);
+ RunWatchdogAction(action, $"Subscription {this}: Keep alives exceeded " +
+ $"({_continuouslyMissingKeepAlives}/{CurrentLifetimeCount}).");
}
else
{
@@ -2146,15 +2171,17 @@ private void OnPublishStatusChange(Subscription subscription, PublishStateChange
}
if (e.Status.HasFlag(PublishStateChangedMask.Timeout))
{
- _logger.LogWarning("Subscription {Subscription} timed out! Re-creating...", this);
+ var action = _template.Configuration?.WatchdogBehavior
+ ?? SubscriptionWatchdogBehavior.Reset;
+ _logger.LogInformation("Subscription {Subscription} TIMEOUT! ---- " +
+ "Server closed subscription - performing recovery action {Action}...",
+ this, action);
//
// Timed out on server - this means that the subscription is gone and
- // needs to be recreated.
+ // needs to be recreated. This is the default watchdog behavior.
//
- _forceRecreate = true;
- _publishingStopped = true;
- OnSubscriptionManagementTriggered(this);
+ RunWatchdogAction(action, $"Subscription {this} timed out!");
}
}
@@ -2560,7 +2587,12 @@ internal record MetaDataLoaderArguments(TaskCompletionSource? tcs,
private readonly OpcUaSubscription _subscription;
}
- private long TotalMonitoredItems => _additionallyMonitored.Count + MonitoredItemCount;
+ private long TotalMonitoredItems
+ => _additionallyMonitored.Count + MonitoredItemCount;
+ private int HeartbeatsEnabled
+ => MonitoredItems.Count(r => r is OpcUaMonitoredItem.Heartbeat h && h.TimerEnabled);
+ private int ConditionsEnabled
+ => MonitoredItems.Count(r => r is OpcUaMonitoredItem.Condition h && h.TimerEnabled);
///
/// Create observable metrics
@@ -2591,6 +2623,18 @@ public void InitializeMetrics()
_meter.CreateObservableUpDownCounter("iiot_edge_publisher_sampling_nodes",
() => new Measurement(_samplingItems, _metrics.TagList),
description: "Monitored items with sampling enabled.");
+ _meter.CreateObservableUpDownCounter("iiot_edge_publisher_heartbeat_enabled_nodes",
+ () => new Measurement(HeartbeatsEnabled, _metrics.TagList),
+ description: "Monitored items with heartbeats enabled.");
+ _meter.CreateObservableUpDownCounter("iiot_edge_publisher_heartbeat_nodes",
+ () => new Measurement(_heartbeatItems, _metrics.TagList),
+ description: "Monitored items with heartbeats configured.");
+ _meter.CreateObservableUpDownCounter("iiot_edge_publisher_condition_enabled_nodes",
+ () => new Measurement(ConditionsEnabled, _metrics.TagList),
+ description: "Monitored items with condition monitoring enabled.");
+ _meter.CreateObservableUpDownCounter("iiot_edge_publisher_condition_nodes",
+ () => new Measurement(_conditionItems, _metrics.TagList),
+ description: "Monitored items with condition monitoring configured.");
_meter.CreateObservableUpDownCounter("iiot_edge_publisher_disabled_nodes",
() => new Measurement(_disabledItems, _metrics.TagList),
description: "Monitored items with monitoring mode disabled.");
@@ -2665,6 +2709,8 @@ public void InitializeMetrics()
private int _disabledItems;
private int _samplingItems;
private int _notAppliedItems;
+ private int _heartbeatItems;
+ private int _conditionItems;
private int _badMonitoredItems;
private int _missingKeepAlives;
private int _continuouslyMissingKeepAlives;
diff --git a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs
index 39de0c7780..4e98725fce 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/src/Storage/PublishedNodesConverter.cs
@@ -134,6 +134,11 @@ public IEnumerable ToPublishedNodes(uint version, Date
DataSetSamplingInterval = null,
DataSetSamplingIntervalTimespan =
item.Writer.DataSet?.DataSetSource?.SubscriptionSettings?.DefaultSamplingInterval,
+ DefaultHeartbeatInterval = null,
+ DefaultHeartbeatIntervalTimespan =
+ item.Writer.DataSet?.DataSetSource?.SubscriptionSettings?.DefaultHeartbeatInterval,
+ DefaultHeartbeatBehavior =
+ item.Writer.DataSet?.DataSetSource?.SubscriptionSettings?.DefaultHeartbeatBehavior,
Priority =
item.Writer.DataSet?.DataSetSource?.SubscriptionSettings?.Priority,
MaxKeepAliveCount =
@@ -467,14 +472,16 @@ public IEnumerable ToWriterGroups(IEnumerable n != kDummyEntry), false),
@@ -529,13 +536,11 @@ IEnumerable GetNodeModels(PublishedNodesEntryModel item, int scale
DataSetClassFieldId = node.DataSetClassFieldId,
DataSetFieldId = node.DataSetFieldId,
ExpandedNodeId = node.ExpandedNodeId,
- HeartbeatIntervalTimespan = node
- .GetNormalizedHeartbeatInterval(),
// The publishing interval item wins over dataset over global default
OpcPublishingIntervalTimespan = node.GetNormalizedPublishingInterval()
?? item.GetNormalizedDataSetPublishingInterval(),
- OpcSamplingIntervalTimespan = node.GetNormalizedSamplingInterval()
- ?? item.GetNormalizedDataSetSamplingInterval(),
+ OpcSamplingIntervalTimespan = node.GetNormalizedSamplingInterval(),
+ HeartbeatIntervalTimespan = node.GetNormalizedHeartbeatInterval(),
QueueSize = node.QueueSize,
DiscardNew = node.DiscardNew,
BrowsePath = node.BrowsePath,
@@ -569,13 +574,11 @@ IEnumerable GetNodeModels(PublishedNodesEntryModel item, int scale
DataSetFieldId = node.DataSetFieldId,
DataSetClassFieldId = node.DataSetClassFieldId,
ExpandedNodeId = node.ExpandedNodeId,
- HeartbeatIntervalTimespan = node
- .GetNormalizedHeartbeatInterval(),
+ HeartbeatIntervalTimespan = node.GetNormalizedHeartbeatInterval(),
// The publishing interval item wins over dataset over global default
OpcPublishingIntervalTimespan = node.GetNormalizedPublishingInterval()
?? item.GetNormalizedDataSetPublishingInterval(),
- OpcSamplingIntervalTimespan = node.GetNormalizedSamplingInterval()
- ?? item.GetNormalizedDataSetSamplingInterval(),
+ OpcSamplingIntervalTimespan = node.GetNormalizedSamplingInterval(),
QueueSize = node.QueueSize,
SkipFirst = node.SkipFirst,
DataChangeTrigger = node.DataChangeTrigger,
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj b/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj
index a21622d7f8..55364e2753 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Azure.IIoT.OpcUa.Publisher.Tests.csproj
@@ -7,7 +7,7 @@
-
+
all
runtime; build; native; contentfiles; analyzers; buildtransitive
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs
new file mode 100644
index 0000000000..4807839743
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/BrowseTests.cs
@@ -0,0 +1,116 @@
+// ------------------------------------------------------------
+// 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.FileSystem
+{
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ 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(FileCollection.Name)]
+ public class BrowseTests
+ {
+ public BrowseTests(FileSystemServer server, ITestOutputHelper output)
+ {
+ _server = server;
+ _output = output;
+ }
+
+ private BrowseTests GetTests()
+ {
+ return new BrowseTests(
+ () => new NodeServices(_server.Client, _server.Parser,
+ _output.BuildLoggerFor>(Logging.Level),
+ new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()),
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly ITestOutputHelper _output;
+
+ [Fact]
+ public Task GetFileSystemsTest1Async()
+ {
+ return GetTests().GetFileSystemsTest1Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest1Async()
+ {
+ return GetTests().GetDirectoriesTest1Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest2Async()
+ {
+ return GetTests().GetDirectoriesTest2Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest3Async()
+ {
+ return GetTests().GetDirectoriesTest3Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest4Async()
+ {
+ return GetTests().GetDirectoriesTest4Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest5Async()
+ {
+ return GetTests().GetDirectoriesTest5Async();
+ }
+
+ [Fact]
+ public Task GetDirectoriesTest6Async()
+ {
+ return GetTests().GetDirectoriesTest6Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest1Async()
+ {
+ return GetTests().GetFilesTest1Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest2Async()
+ {
+ return GetTests().GetFilesTest2Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest3Async()
+ {
+ return GetTests().GetFilesTest3Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest4Async()
+ {
+ return GetTests().GetFilesTest4Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest5Async()
+ {
+ return GetTests().GetFilesTest5Async();
+ }
+
+ [Fact]
+ public Task GetFilesTest6Async()
+ {
+ return GetTests().GetFilesTest6Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/FileCollection.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/FileCollection.cs
new file mode 100644
index 0000000000..953a31252f
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/FileCollection.cs
@@ -0,0 +1,16 @@
+// ------------------------------------------------------------
+// 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.FileSystem
+{
+ using Azure.IIoT.OpcUa.Publisher.Testing.Fixtures;
+ using Xunit;
+
+ [CollectionDefinition(Name)]
+ public class FileCollection : ICollectionFixture
+ {
+ public const string Name = "FileSystem";
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs
new file mode 100644
index 0000000000..a53a7745b8
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/OperationsTests.cs
@@ -0,0 +1,152 @@
+// ------------------------------------------------------------
+// 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.FileSystem
+{
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ 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(FileCollection.Name)]
+ public class OperationsTests
+ {
+ public OperationsTests(FileSystemServer server, ITestOutputHelper output)
+ {
+ _server = server;
+ _output = output;
+ }
+
+ private OperationsTests GetTests()
+ {
+ return new OperationsTests(
+ () => new NodeServices(_server.Client, _server.Parser,
+ _output.BuildLoggerFor>(Logging.Level),
+ new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()),
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly ITestOutputHelper _output;
+
+ [Fact]
+ public Task CreateDirectoryTest1Async()
+ {
+ return GetTests().CreateDirectoryTest1Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest2Async()
+ {
+ return GetTests().CreateDirectoryTest2Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest3Async()
+ {
+ return GetTests().CreateDirectoryTest3Async();
+ }
+
+ [Fact]
+ public Task CreateDirectoryTest4Async()
+ {
+ return GetTests().CreateDirectoryTest4Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest1Async()
+ {
+ return GetTests().DeleteDirectoryTest1Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest2Async()
+ {
+ return GetTests().DeleteDirectoryTest2Async();
+ }
+
+ [Fact]
+ public Task DeleteDirectoryTest3Async()
+ {
+ return GetTests().DeleteDirectoryTest3Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest1Async()
+ {
+ return GetTests().CreateFileTest1Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest2Async()
+ {
+ return GetTests().CreateFileTest2Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest3Async()
+ {
+ return GetTests().CreateFileTest3Async();
+ }
+
+ [Fact]
+ public Task CreateFileTest4Async()
+ {
+ return GetTests().CreateFileTest4Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest1Async()
+ {
+ return GetTests().GetFileInfoTest1Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest2Async()
+ {
+ return GetTests().GetFileInfoTest2Async();
+ }
+
+ [Fact]
+ public Task GetFileInfoTest3Async()
+ {
+ return GetTests().GetFileInfoTest3Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest1Async()
+ {
+ return GetTests().DeleteFileTest1Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest2Async()
+ {
+ return GetTests().DeleteFileTest2Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest3Async()
+ {
+ return GetTests().DeleteFileTest3Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest4Async()
+ {
+ return GetTests().DeleteFileTest4Async();
+ }
+
+ [Fact]
+ public Task DeleteFileTest5Async()
+ {
+ return GetTests().DeleteFileTest5Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs
new file mode 100644
index 0000000000..4a945ee338
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/ReadTests.cs
@@ -0,0 +1,68 @@
+// ------------------------------------------------------------
+// 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.FileSystem
+{
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ 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(FileCollection.Name)]
+ public class ReadTests
+ {
+ public ReadTests(FileSystemServer server, ITestOutputHelper output)
+ {
+ _server = server;
+ _output = output;
+ }
+
+ private ReadTests GetTests()
+ {
+ return new ReadTests(
+ () => new NodeServices(_server.Client, _server.Parser,
+ _output.BuildLoggerFor>(Logging.Level),
+ new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()),
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly ITestOutputHelper _output;
+
+ [Fact]
+ public Task ReadFileTest0Async()
+ {
+ return GetTests().ReadFileTest0Async();
+ }
+
+ [Fact]
+ public Task ReadFileTest1Async()
+ {
+ return GetTests().ReadFileTest1Async();
+ }
+
+ [Fact]
+ public Task ReadFileTest2Async()
+ {
+ return GetTests().ReadFileTest2Async();
+ }
+
+ [Fact]
+ public Task ReadFileTest3Async()
+ {
+ return GetTests().ReadFileTest3Async();
+ }
+
+ [Fact]
+ public Task ReadFileTest4Async()
+ {
+ return GetTests().ReadFileTest4Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.cs
new file mode 100644
index 0000000000..6b883943cb
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/FileSystem/WriteTests.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.Tests.Services.FileSystem
+{
+ using Azure.IIoT.OpcUa.Publisher.Models;
+ 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(FileCollection.Name)]
+ public class WriteTests
+ {
+ public WriteTests(FileSystemServer server, ITestOutputHelper output)
+ {
+ _server = server;
+ _output = output;
+ }
+
+ private WriteTests GetTests()
+ {
+ return new WriteTests(
+ () => new NodeServices(_server.Client, _server.Parser,
+ _output.BuildLoggerFor>(Logging.Level),
+ new PublisherConfig(new ConfigurationBuilder().Build()).ToOptions()),
+ _server.GetConnection(), _server.TempPath);
+ }
+
+ private readonly FileSystemServer _server;
+ private readonly ITestOutputHelper _output;
+
+ [Fact]
+ public Task WriteFileTest0Async()
+ {
+ return GetTests().WriteFileTest0Async();
+ }
+
+ [Fact]
+ public Task WriteFileTest1Async()
+ {
+ return GetTests().WriteFileTest1Async();
+ }
+
+ [Fact]
+ public Task WriteFileTest2Async()
+ {
+ return GetTests().WriteFileTest2Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest0Async()
+ {
+ return GetTests().AppendFileTest0Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest1Async()
+ {
+ return GetTests().AppendFileTest1Async();
+ }
+
+ [Fact]
+ public Task AppendFileTest2Async()
+ {
+ return GetTests().AppendFileTest2Async();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs
index 9b43229c60..30b06b5ddf 100644
--- a/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs
+++ b/src/Azure.IIoT.OpcUa.Publisher/tests/Services/PublisherConfigServicesTests.cs
@@ -1088,6 +1088,39 @@ public async Task UpdateConfiguredEndpoints()
results.Should().BeEmpty();
}
+ [Fact]
+ public async Task UpdateConfiguredEndpointsWithBrowsePaths()
+ {
+ await using var configService = InitPublisherConfigService();
+ var opcNodes = Enumerable.Range(0, 101)
+ .Select(i => new OpcNodeModel
+ {
+ Id = null,
+ BrowsePath = new[]
+ {
+ "Objects",
+ "Telemetry",
+ $"FastUInt{i}"
+ },
+ DataSetFieldId = "alwaysthesameid"
+ })
+ .ToList();
+ var items = Enumerable.Range(1, 100).Select(i => GenerateEndpoint(i, opcNodes, false)).ToList();
+ await configService.SetConfiguredEndpointsAsync(items);
+
+ var results = await configService.GetConfiguredEndpointsAsync(false);
+ results.Count.Should().Be(100);
+
+ await configService.UnpublishAllNodesAsync(results[50]);
+ results = await configService.GetConfiguredEndpointsAsync(false);
+ results.Count.Should().Be(99);
+
+ // purge
+ await configService.UnpublishAllNodesAsync(new PublishedNodesEntryModel());
+ results = await configService.GetConfiguredEndpointsAsync(false);
+ results.Should().BeEmpty();
+ }
+
[Fact]
public async Task Legacy25PublishedNodesFileError()
{
diff --git a/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj b/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj
index 81e6a75010..fee17e4c66 100644
--- a/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj
+++ b/src/Azure.IIoT.OpcUa/src/Azure.IIoT.OpcUa.csproj
@@ -9,7 +9,7 @@
-
+
diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/FileSystemServicesEx.cs b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/FileSystemServicesEx.cs
new file mode 100644
index 0000000000..5870b07485
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/FileSystemServicesEx.cs
@@ -0,0 +1,75 @@
+// ------------------------------------------------------------
+// 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.Diagnostics;
+ using System.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ ///
+ /// File system services extensions
+ ///
+ public static class FileSystemServicesEx
+ {
+ ///
+ /// Copy from server to provided stream (e.g. file)
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task CopyToAsync(this IFileSystemServices service,
+ T endpoint, FileSystemObjectModel file, Stream stream, CancellationToken ct = default)
+ {
+ var open = await service.OpenReadAsync(endpoint, file, ct).ConfigureAwait(false);
+ if (open.ErrorInfo != null)
+ {
+ Debug.Assert(open.Result == null);
+ return open.ErrorInfo;
+ }
+ Debug.Assert(open.Result != null);
+ await using (var _ = open.Result.ConfigureAwait(false))
+ {
+ await open.Result.CopyToAsync(stream, ct).ConfigureAwait(false);
+ }
+ return new ServiceResultModel();
+ }
+
+ ///
+ /// Copy from stream (e.g. file) to file on server
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ public static async Task CopyFromAsync(this IFileSystemServices service,
+ T endpoint, FileSystemObjectModel file, Stream stream, FileWriteMode mode = FileWriteMode.Create,
+ CancellationToken ct = default)
+ {
+ var open = await service.OpenWriteAsync(endpoint, file, mode, ct).ConfigureAwait(false);
+ if (open.ErrorInfo != null)
+ {
+ Debug.Assert(open.Result == null);
+ return open.ErrorInfo;
+ }
+ Debug.Assert(open.Result != null);
+ await using (var _ = open.Result.ConfigureAwait(false))
+ {
+ await stream.CopyToAsync(open.Result, ct).ConfigureAwait(false);
+ }
+ return new ServiceResultModel();
+ }
+ }
+}
diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs
index 3d4a1c9322..3db9578d83 100644
--- a/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs
+++ b/src/Azure.IIoT.OpcUa/src/Publisher/Extensions/PublishedNodesEntryModelEx.cs
@@ -316,6 +316,15 @@ public static string GetUniqueDataSetWriterId(this PublishedNodesEntryModel mode
{
id.Append(samplingInterval.Value.TotalMilliseconds);
}
+ var heartbeatInterval = model.GetNormalizedDefaultHeartbeatInterval();
+ if (heartbeatInterval != null)
+ {
+ id.Append(heartbeatInterval.Value.TotalMilliseconds);
+ }
+ if (model.DefaultHeartbeatBehavior != null)
+ {
+ id.Append(model.DefaultHeartbeatBehavior.Value);
+ }
if (model.QualityOfService != null)
{
id.Append(model.QualityOfService.Value);
@@ -476,6 +485,15 @@ public static bool HasSameDataSet(this PublishedNodesEntryModel model,
{
return false;
}
+ if (model.GetNormalizedDefaultHeartbeatInterval() !=
+ that.GetNormalizedDefaultHeartbeatInterval())
+ {
+ return false;
+ }
+ if (model.DefaultHeartbeatBehavior != that.DefaultHeartbeatBehavior)
+ {
+ return false;
+ }
if (model.QualityOfService != that.QualityOfService)
{
return false;
@@ -560,6 +578,17 @@ public static bool HasSameDataSet(this PublishedNodesEntryModel model,
.GetTimeSpanFromMiliseconds(model.DataSetSamplingInterval);
}
+ ///
+ /// Retrieves the timespan flavor of a PublishedNodesEntryModel's DefaultHeartbeatInterval
+ ///
+ ///
+ public static TimeSpan? GetNormalizedDefaultHeartbeatInterval(
+ this PublishedNodesEntryModel model)
+ {
+ return model.DefaultHeartbeatIntervalTimespan
+ .GetTimeSpanFromMiliseconds(model.DefaultHeartbeatInterval);
+ }
+
///
/// Retrieves the timespan flavor of a PublishedNodesEntryModel's DataSetPublishingInterval
///
diff --git a/src/Azure.IIoT.OpcUa/src/Publisher/IFileSystemServices.cs b/src/Azure.IIoT.OpcUa/src/Publisher/IFileSystemServices.cs
new file mode 100644
index 0000000000..726522a873
--- /dev/null
+++ b/src/Azure.IIoT.OpcUa/src/Publisher/IFileSystemServices.cs
@@ -0,0 +1,132 @@
+// ------------------------------------------------------------
+// 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.IO;
+ using System.Threading;
+ using System.Threading.Tasks;
+
+ ///
+ /// File system services expose services as per the file transfer specification
+ /// https://reference.opcfoundation.org/Core/Part20/v105/docs/#4.3.3.
+ ///
+ ///
+ public interface IFileSystemServices
+ {
+ ///
+ /// Get all file systems on the server
+ ///
+ ///
+ ///
+ ///
+ IAsyncEnumerable> GetFileSystemsAsync(
+ T endpoint, CancellationToken ct = default);
+
+ ///
+ /// Get all directories under a filesystem or directory
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task>> GetDirectoriesAsync(
+ T endpoint, FileSystemObjectModel fileSystemOrDirectory,
+ CancellationToken ct = default);
+
+ ///
+ /// Get all files in a directory or filesystem
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task>> GetFilesAsync(
+ T endpoint, FileSystemObjectModel fileSystemOrDirectory,
+ CancellationToken ct = default);
+
+ ///
+ /// Get parent directory or filesystem
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> GetParentAsync(T endpoint,
+ FileSystemObjectModel fileOrDirectoryObject, CancellationToken ct = default);
+
+ ///
+ /// Get file information for a file
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> GetFileInfoAsync(T endpoint,
+ FileSystemObjectModel file, CancellationToken ct = default);
+
+ ///
+ /// Opens the file for reading. Closing the stream will close the file.
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> OpenReadAsync(T endpoint, FileSystemObjectModel file,
+ CancellationToken ct = default);
+
+ ///
+ /// Opens the file for writing, closing the stream will close the file.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> OpenWriteAsync(T endpoint, FileSystemObjectModel file,
+ FileWriteMode mode = FileWriteMode.Create, CancellationToken ct = default);
+
+ ///
+ /// Create parent directory under a file system or directory.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> CreateDirectoryAsync(T endpoint,
+ FileSystemObjectModel fileSystemOrDirectory, string name,
+ CancellationToken ct = default);
+
+ ///
+ /// Create a file in the directory
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task> CreateFileAsync(T endpoint,
+ FileSystemObjectModel fileSystemOrDirectory, string name,
+ CancellationToken ct = default);
+
+ ///
+ /// Delete a file or directory with the name from the directory
+ /// or filesystem. If the name is omitted the object is itself
+ /// deleted from its parent object.
+ ///
+ ///
+ ///
+ ///
+ ///
+ ///
+ Task DeleteFileSystemObjectAsync(T endpoint,
+ FileSystemObjectModel fileOrDirectoryObject,
+ FileSystemObjectModel? parentFileSystemOrDirectory = null,
+ CancellationToken ct = default);
+ }
+}
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 a6845245aa..6d4ac8be84 100644
--- a/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj
+++ b/src/Azure.IIoT.OpcUa/tests/Azure.IIoT.OpcUa.Tests.csproj
@@ -8,13 +8,13 @@
all
runtime; build; native; contentfiles; analyzers; buildtransitive
-
+
all
runtime; build; native; contentfiles; analyzers
-
+