Skip to content

Commit

Permalink
Add client low-level file system functionality (#696)
Browse files Browse the repository at this point in the history
* Add low-level file system functionality

* Added examples for the use of ua_file_transfer.py

In the following you will find examples for the use of the
classes UaFile and UaDirectory and how to handle typical uaerrors.
see: ./asyncua/client/ua_file_transfer.py.

The OPC UA File Transfer specification can be found here:
https://reference.opcfoundation.org/Core/docs/Part5/C.1/

* pylint optimized - ua_file_transfer.py 

pylint optimization of file ua_file_transfer.py.
=> Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

* pylint optimized - client_ua_file_transfer.py

pylint optimization of client_ua_file_transfer.py.
=> Your code has been rated at 10.00/10 (previous run: 10.00/10, +0.00)

Co-authored-by: Curious Crook <privat@nowhere.org>
  • Loading branch information
CuriousCrook and Curious Crook authored Nov 3, 2021
1 parent 0f4f68c commit f57477a
Show file tree
Hide file tree
Showing 2 changed files with 343 additions and 0 deletions.
255 changes: 255 additions & 0 deletions asyncua/client/ua_file_transfer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,255 @@
"""
Low level implementation of OPC UA File Transfer.
This module contains the mandatory functionality specified
by the OPC Foundation.
See also:
OPC 10000-5: OPC Unified Architecture V1.04
Part 5: Information Model - Annex C (normative) File Transfer
https://reference.opcfoundation.org/Core/docs/Part5/C.1/
"""
import logging

from asyncua.ua import OpenFileMode, Variant, VariantType, NodeId
from asyncua.common.node import Node

_logger = logging.getLogger(__name__)


class UaFile:
"""
Provides the functionality to work with "C.2 FileType".
"""
def __init__(self, file_node: Node, open_mode: OpenFileMode = OpenFileMode.Read.value):
self._file_node = file_node
self._open_mode = open_mode
self._file_handle = None

async def __aenter__(self):
await self.open(self._open_mode)
return self

async def __aexit__(self, exc_type, exc_value, traceback):
return await self.close()

async def open(self, open_mode: OpenFileMode = None) -> None:
"""
Open is used to open a file represented by an Object of FileType.
The open mode of OPC UA differs significantly from the
python build-in functionality.
=> See the OPC UA specification for more information.
:param open_mode: Open mode defined in C.2.1.
:return: The file handle.
"""
_logger.debug("Request to open file %s in mode: %s", self._file_node, OpenFileMode)
open_node = await self._file_node.get_child("Open")
arg1_mode = Variant(open_mode or self._open_mode, VariantType.Byte)
self._file_handle = await self._file_node.call_method(open_node, arg1_mode)

async def close(self) -> None:
"""
Close is used to close a file represented by a FileType.
When a client closes a file the handle becomes invalid.
"""
_logger.debug("Request to close file %s", self._file_node)
read_node = await self._file_node.get_child("Close")
arg1_file_handle = Variant(self._file_handle, VariantType.UInt32)
await self._file_node.call_method(read_node, arg1_file_handle)

async def read(self) -> bytes:
"""
Read is used to read a part of the file starting from the current file position.
The file position is advanced by the number of bytes read.
:return: Contains the returned data of the file.
If the ByteString is empty it indicates that the end of the file is reached.
"""
_logger.debug("Request to read from file %s", self._file_node)
size = await self.get_size()
read_node = await self._file_node.get_child("Read")
arg1_file_handle = Variant(self._file_handle, VariantType.UInt32)
arg2_length = Variant(size, VariantType.Int32)
return await self._file_node.call_method(read_node, arg1_file_handle, arg2_length)

async def write(self, data: bytes) -> None:
"""
Write is used to write a part of the file starting from the current file position.
The file position is advanced by the number of bytes written.
:param data: Contains the data to be written at the position of the file.
It is server-dependent whether the written data are persistently
stored if the session is ended without calling the Close Method with the fileHandle.
Writing an empty or null ByteString returns a Good result code without any
affect on the file.
"""
_logger.debug("Request to write to file %s", self._file_node)
write_node = await self._file_node.get_child("Write")
arg1_file_handle = Variant(self._file_handle, VariantType.UInt32)
arg2_data = Variant(data, VariantType.ByteString)
await self._file_node.call_method(write_node, arg1_file_handle, arg2_data)

async def get_position(self) -> int:
"""
GetPosition is used to provide the current position of the file handle.
:return: The position of the fileHandle in the file.
If a Read or Write is called it starts at that position.
"""
_logger.debug("Request to get position from file %s", self._file_node)
get_position_node = await self._file_node.get_child("GetPosition")
arg1_file_handle = Variant(self._file_handle, VariantType.UInt32)
return await self._file_node.call_method(get_position_node, arg1_file_handle)

async def set_position(self, position: int) -> None:
"""
SetPosition is used to set the current position of the file handle.
:param position: The position to be set for the fileHandle in the file.
If a Read or Write is called it starts at that position.
If the position is higher than the file size the position is set to the end of the file.
"""
_logger.debug("Request to set position in file %s", self._file_node)
set_position_node = await self._file_node.get_child("SetPosition")
arg1_file_handle = Variant(self._file_handle, VariantType.UInt32)
arg2_position = Variant(position, VariantType.UInt64)
return await self._file_node.call_method(set_position_node, arg1_file_handle, arg2_position)

async def get_size(self) -> int:
"""
Size defines the size of the file in Bytes.
When a file is opened for write the size might not be accurate.
:return: The size of the file in Bytes.
"""
_logger.debug("Request to get size of file %s", self._file_node)
size_node = await self._file_node.get_child("Size")
return await size_node.read_value()

async def get_writable(self) -> bool:
"""
Writable indicates whether the file is writable.
It does not take any user access rights into account, i.e. although the file
is writable this may be restricted to a certain user / user group.
The Property does not take into account whether the file is currently
opened for writing by another client and thus currently locked and not writable by others.
:return:
"""
_logger.debug("Request to get writable of file %s", self._file_node)
writable_node = await self._file_node.get_child("Writable")
return await writable_node.read_value()

async def get_user_writable(self) -> bool:
"""
UserWritable indicates whether the file is writable taking user access rights into account.
The Property does not take into account whether the file is currently opened
for writing by another client and thus currently locked and not writable by others.
:return: Indicates whether the file is writable taking user access rights into account
"""
_logger.debug("Request to get user writable of file %s", self._file_node)
user_writable_node = await self._file_node.get_child("UserWritable")
return await user_writable_node.read_value()

async def get_open_count(self):
"""
OpenCount indicates the number of currently valid file handles on the file.
:return: Amount of currently valid file handles on the file
"""
_logger.debug("Request to get open count of file %s", self._file_node)
open_count_node = await self._file_node.get_child("OpenCount")
return await open_count_node.read_value()


class UaDirectory:
"""
Provides the functionality to work with "C.3 File System".
"""
def __init__(self, directory_node):
self._directory_node = directory_node

async def create_directory(self, directory_name: str) -> NodeId:
"""
CreateDirectory is used to create a new FileDirectoryType Object organized by this Object.
:param directory_name: The name of the directory to create.
The name is used for the BrowseName and DisplayName of the directory object and also
for the directory in the file system.
For the BrowseName, the directoryName is used for the name part of the QualifiedName.
The namespace index is Server specific.
For the DisplayName, the directoryName is used for the text part of the LocalizedText.
The locale part is Server specific.
:return: The NodeId of the created directory Object.
"""
_logger.debug("Request to create directory %s in %s", directory_name, self._directory_node)
create_directory_node = await self._directory_node.get_child("CreateDirectory")
arg1_directory_name = Variant(directory_name, VariantType.String)
return await self._directory_node.call_method(create_directory_node, arg1_directory_name)

async def create_file(self, file_name: str, request_file_open: bool) -> [NodeId, int]:
"""
CreateFile is used to create a new FileType Object organized by this Object.
The created file can be written using the Write Method of the FileType.
:param file_name: The name of the file to create. The name is used for the
BrowseName and DisplayName of the file object and also for the file in the
file system.
For the BrowseName, the fileName is used for the name part of the QualifiedName.
The namespace index is Server specific. For the DisplayName, the fileName is
used for the text part of the LocalizedText. The locale part is Server specific.
:param request_file_open: Flag indicating if the new file should be opened
with the Write and Read bits set in the open mode after the creation of the file.
If the flag is set to True, the file is created and opened for writing.
If the flag is set to False, the file is just created.
:return: The NodeId of the created file Object.
The fileHandle is returned if the requestFileOpen is set to True.
The fileNodeId and the fileHandle can be used to access the new file
through the FileType Object representing the new file.
If requestFileOpen is set to False, the returned value shall be 0
and shall be ignored by the caller.
"""
_logger.debug("Request to create file %s in %s", file_name, self._directory_node)
print(f"Request to create file {file_name} in {self._directory_node}")
create_file_node = await self._directory_node.get_child("CreateFile")
arg1_file_name = Variant(file_name, VariantType.String)
arg2_request_file_open = Variant(request_file_open, VariantType.Boolean)
return await self._directory_node.call_method(create_file_node,
arg1_file_name,
arg2_request_file_open)

async def delete(self, object_to_delete: NodeId) -> None:
"""
Delete is used to delete a file or directory organized by this Object.
:param object_to_delete: The NodeId of the file or directory to delete.
In the case of a directory, all file and directory Objects below the
directory to delete are deleted recursively.
"""
_logger.debug("Request to delete file %s from %s", object_to_delete, self._directory_node)
delete_node = await self._directory_node.get_child("Delete")
await self._directory_node.call_method(delete_node, object_to_delete)

async def move_or_copy(self,
object_to_move_or_copy: NodeId,
target_directory: NodeId,
create_copy: bool,
new_name: str) -> NodeId:
"""
MoveOrCopy is used to move or copy a file or directory organized by this Object
to another directory or to rename a file or directory.
:param object_to_move_or_copy: The NodeId of the file or directory to move or copy.
:param target_directory: The NodeId of the target directory of the move or copy command.
If the file or directory is just renamed, the targetDirectory matches the ObjectId
passed to the method call.
:param create_copy: A flag indicating if a copy of the file or directory should be
created at the target directory.
:param new_name: The new name of the file or directory in the new location.
If the string is empty, the name is unchanged.
:return: The NodeId of the moved or copied object. Even if the Object is moved,
the Server may return a new NodeId.
"""
_logger.debug("Request to %s%s file system object %s from %s to %s, new name=%s",
'' if create_copy else 'move',
'copy' if create_copy else '',
object_to_move_or_copy,
self._directory_node,
target_directory,
new_name)
move_or_copy_node = await self._directory_node.get_child("MoveOrCopy")
arg3_create_copy = Variant(create_copy, VariantType.Boolean)
arg4_new_name = Variant(new_name, VariantType.String)
return await self._directory_node.call_method(move_or_copy_node,
object_to_move_or_copy,
target_directory,
arg3_create_copy,
arg4_new_name)
88 changes: 88 additions & 0 deletions examples/client_ua_file_transfer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"""
In the following you will find examples for the use of the
classes UaFile and UaDirectory and how to handle typical uaerrors.
see: ./asyncua/client/ua_file_transfer.py.
The two classes are close to OPC UA File Transfer. This means they
have to be operated "OPC UA typical" and not "Python typical".
The advantage of this case is, that you can use the maximum
range of functions of OPC UA File Transfer.
See: https://reference.opcfoundation.org/Core/docs/Part5/C.1/
IMPORTANT NOTE:
In order to to test the functions from UaFile and UaDiretory,
you need an OPC UA server that offers the required File Transfer functionality.
However, in this project there is currently no demo server containing
file transfer capabilities.
"""
import asyncio
import logging

from asyncua import Client
from asyncua.client.ua_file_transfer import UaFile, UaDirectory
from asyncua.ua import OpenFileMode
from asyncua.ua import uaerrors

_logger = logging.getLogger("asyncua")


async def task():
"""All communication takes place within this task.
For the sake of simplicity, all (OPC UA) calls are executed purely sequentially.
"""
url = "opc.tcp://localhost:4840/freeopcua/server/"

async with Client(url=url) as client:
uri = "http://examples.freeopcua.github.io"
idx = await client.get_namespace_index(uri)

remote_file_system = await client.nodes.objects.get_child([f"{idx}:FileSystem"])
remote_file_name = "test.txt"
remote_file_node = await remote_file_system.get_child([f"{idx}:{remote_file_name}"])

# Read file from server
remote_file_content = None
async with UaFile(remote_file_node, OpenFileMode.Read.value) as remote_file:
remote_file_content = await remote_file.read()
print("File content:")
print(remote_file_content, end="\n\n")

# Create file on server
new_file_name = "new_file.txt"
ua_dir = UaDirectory(remote_file_system)
try:
await ua_dir.create_file(new_file_name, False)
except uaerrors.BadBrowseNameDuplicated:
_logger.warning("=> File '%s' already exists on server.", new_file_name)

# Write to file on server
file_content = ("I am a random file\n" * 3).encode('utf-8')
# In order to write to a file, it must already exist on the target system. (OPC UA typical)
remote_file_node = await remote_file_system.get_child(f"{uri}:{new_file_name}")
# In order to write to a file, you need the OpenFileModes "Write"
# and one of the following "Append" or "EraseExisting". (OPC UA typical too)
async with UaFile(remote_file_node,
OpenFileMode.Write + OpenFileMode.EraseExisting) as remote_file:
await remote_file.write(file_content)

# Append to file on server
file_content = ("I am appended text\n" * 3).encode('utf-8')
remote_file_node = await remote_file_system.get_child(f"{uri}:{new_file_name}")
async with UaFile(remote_file_node,
OpenFileMode.Write + OpenFileMode.Append) as remote_file:
await remote_file.write(file_content)

# Get size of remote file
file_size = await UaFile(remote_file_node).get_size()
print(f"Size of file node '{remote_file_node}' = {file_size} byte")

# Delete file from server
try:
await ua_dir.delete(remote_file_node.nodeid)
except uaerrors.BadNotFound:
_logger.warning("File %s not found on server.", remote_file_node)


if __name__ == "__main__":
logging.basicConfig(level=logging.WARNING)
asyncio.run(task())

0 comments on commit f57477a

Please sign in to comment.