Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
369 changes: 369 additions & 0 deletions lewis/adapters/opcua.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,369 @@
# -*- coding: utf-8 -*-
# *********************************************************************
# lewis - a library for creating hardware device simulators
# Copyright (C) 2016-2021 European Spallation Source ERIC
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
# **

"""
This module provides components to expose a Device via a OPCUA Protocol. The following resources
were used as guidelines and references for implementing the protocol:

- https://opcfoundation.org/wp-content/uploads/2014/05/OPC-UA_Security_EN.pdf
- https://www.opc-router.com/what-is-opc-ua/
- https://www.unified-automation.com/
- https://github.com/bashwork/pymodbus

.. note::

For an example how Modbus can be used in the current implementation, please look
at lewis/examples/modbus_device.
"""

import asyncio

Check failure on line 35 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (F401)

lewis/adapters/opcua.py:35:8: F401 `asyncio` imported but unused

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'asyncio' is not used.

Copilot Autofix

AI 6 months ago

To fix the issue, we will remove the unused asyncio import from line 35. This will clean up the code and eliminate the unnecessary dependency. No other changes are required since the module is not used in the visible snippet.


Suggested changeset 1
lewis/adapters/opcua.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lewis/adapters/opcua.py b/lewis/adapters/opcua.py
--- a/lewis/adapters/opcua.py
+++ b/lewis/adapters/opcua.py
@@ -34,3 +34,2 @@
 
-import asyncio
 import inspect
EOF
@@ -34,3 +34,2 @@

import asyncio
import inspect
Copilot is powered by AI and may make mistakes. Always verify output.
import inspect
import threading
import time
from typing import Any, Dict, Optional, List

Check failure on line 39 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (F401)

lewis/adapters/opcua.py:39:41: F401 `typing.List` imported but unused

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'List' is not used.

Copilot Autofix

AI 6 months ago

The best way to fix this issue is to remove the unused List import from the typing module. This involves editing the import statement on line 39 to exclude List while retaining the other types (Any, Dict, and Optional) that may still be in use. This change will not affect the functionality of the code.

Suggested changeset 1
lewis/adapters/opcua.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lewis/adapters/opcua.py b/lewis/adapters/opcua.py
--- a/lewis/adapters/opcua.py
+++ b/lewis/adapters/opcua.py
@@ -38,3 +38,3 @@
 import time
-from typing import Any, Dict, Optional, List
+from typing import Any, Dict, Optional
 from asyncua import Server, Node, ua
EOF
@@ -38,3 +38,3 @@
import time
from typing import Any, Dict, Optional, List
from typing import Any, Dict, Optional
from asyncua import Server, Node, ua
Copilot is powered by AI and may make mistakes. Always verify output.
from asyncua import Server, Node, ua
from asyncua.common.methods import uamethod

Check failure on line 41 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (F401)

lewis/adapters/opcua.py:41:36: F401 `asyncua.common.methods.uamethod` imported but unused

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'uamethod' is not used.

Copilot Autofix

AI 6 months ago

The best way to fix this issue is to remove the unused import statement from asyncua.common.methods import uamethod. This will clean up the code and eliminate the unnecessary dependency. No other changes are required since the removal of this import does not affect the functionality of the code.

Suggested changeset 1
lewis/adapters/opcua.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lewis/adapters/opcua.py b/lewis/adapters/opcua.py
--- a/lewis/adapters/opcua.py
+++ b/lewis/adapters/opcua.py
@@ -40,3 +40,3 @@
 from asyncua import Server, Node, ua
-from asyncua.common.methods import uamethod
+
 
EOF
@@ -40,3 +40,3 @@
from asyncua import Server, Node, ua
from asyncua.common.methods import uamethod


Copilot is powered by AI and may make mistakes. Always verify output.

from lewis.core.adapters import Adapter
from lewis.core.devices import InterfaceBase

Check failure on line 44 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (F401)

lewis/adapters/opcua.py:44:32: F401 `lewis.core.devices.InterfaceBase` imported but unused

Check notice

Code scanning / CodeQL

Unused import Note

Import of 'InterfaceBase' is not used.

Copilot Autofix

AI 6 months ago

To fix the issue, the unused import of InterfaceBase should be removed from the file. This will clean up the code and eliminate the unnecessary dependency. No other changes are required, as the removal of this import does not affect the functionality of the code.

Suggested changeset 1
lewis/adapters/opcua.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lewis/adapters/opcua.py b/lewis/adapters/opcua.py
--- a/lewis/adapters/opcua.py
+++ b/lewis/adapters/opcua.py
@@ -43,3 +43,2 @@
 from lewis.core.adapters import Adapter
-from lewis.core.devices import InterfaceBase
 from lewis.core.logging import has_log
EOF
@@ -43,3 +43,2 @@
from lewis.core.adapters import Adapter
from lewis.core.devices import InterfaceBase
from lewis.core.logging import has_log
Copilot is powered by AI and may make mistakes. Always verify output.
from lewis.core.logging import has_log

Check failure on line 45 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (I001)

lewis/adapters/opcua.py:35:1: I001 Import block is un-sorted or un-formatted

@has_log
class OPCUAAdapter(Adapter):
"""
Adapter for exposing a device via OPCUA.

This adapter creates an OPCUA server that exposes the device's
properties and methods as OPCUA nodes. It handles mapping device properties to OPCUA
noes and translating method calls from OPCUA to device method calls.

:param options: Configuration options for the adapter.
"""

default_options = {
'port' : 4840, #Default OPCUA port
'server_name': 'Lewis OPCUA Server',
'uri': 'urn:lewis:opcua',
'update_interval': 0.1, #Interval for updating variables in seconds
'exclude_properties': [], #Properties excluded from exposure
'read_only_properties': [], #Properties that should be read-only
'security_mode' : 'None', #Security mode options: None, Sign, SignAndEncrypt
'security_policy' : 'None', #Security policy options: None, Basic128Rsa15, Basic256, Basic256Sha256

Check failure on line 67 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (E501)

lewis/adapters/opcua.py:67:101: E501 Line too long (107 > 100)
'certificate' : None, #Path to certificate
'private_key' : None, #Path to private key file
}

protocol = 'opcua'

def __init__(self, options: Optional[Dict[str, Any]] = None) -> None:
"""Initialise the OPCUA adapter with given options."""
super(OPCUAAdapter, self).__init__(options)

#Init member variables
self._server = None
self._running = False
self._nodes = {}
self._update_thread = None
self._stop_event = threading.Event()

#Track property values to detect changes
self._property_values = {}

def start_server(self) -> None:
"""
Start the OPCUA server.

This method initialises the OPCUA server, creates the address space,
populates it with nodes that represent device properties and methods,
and starts the server.
"""

if self._running:
return

#Create server
self._server = Server()

#Setup server parameters
endpoint = F"opc.tcp://0.0.0.0:{self._options.port}"
self._server.set_endpoint(endpoint)
self._server.set_server_name(self._options.server_name)

#Configure security if specified
if (self._option.security_mode != 'None' and
self._options.security_policy != 'None' and
self._options.certificate and
self._options.private_key):
self._server.load_certificate(self._options.certificate)
self._server.load_private_key(self._options.private_key)

#Apply security settings
security_string = f"{self._options.security_policy}, {self._options.security_mode}"
self._server.set_security_policy([security_string])

#Setup namespace
uri = self._option.uri
idx = self._server.register_namespace(uri)

#Create node to store the device
objects = self._server.get_objects_node()
device_node = objects.add_object(idx, "Device")

#Add properties as variables and methods
if self.interface:
self._add_properties(idx, device_node)
self._add_methods(idx, device_node)

#Start the server
self._server.start()
self._running = True

#Start the update thread for periodic property updates
self._stop_event.clear()
self._update_thread = threading.Thread(
target=self._update_variables,
daemon=True
)
self._update_thread.start()

self.log.info(f"OPCUA Server started on {endpoint}")


def _add_properties(self, idx: int, device_node: Node) -> None:
"""
Add device properties as OPCUA variables.

:param idx: Namespace index
:param device_node: Device node to add the properties to
"""

for property in dir(self.interface):
#Skip the excluded properties, internal properties, and methods
if (property in self._options.exclude_properties or
property.startswith('_') or
callable(getattr(self.interface, property))):
continue

#Get property value
value = getattr(self.interface, property)

#Determine if the property is writable
writeable = property not in self._options.read_only_properties

#Determine data type
data_type = self._get_ua_data_type(value)

#Create the variable node
var = device_node.add_variable(
idx,
property,
value,
data_type
)
var.set_writeable(writeable)

#Store the node for updates
self._nodes[property] = var

#Store initial value
self._property_values[property] = value

#If writeable, set up callback to handle writes
if writeable:
#definte write callback that updates the device
def make_callback(property_name):

Check failure on line 190 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (ANN001)

lewis/adapters/opcua.py:190:35: ANN001 Missing type annotation for function argument `property_name`

Check failure on line 190 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (ANN202)

lewis/adapters/opcua.py:190:21: ANN202 Missing return type annotation for private function `make_callback`
def write_callback(node, val):

Check failure on line 191 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (ANN001)

lewis/adapters/opcua.py:191:40: ANN001 Missing type annotation for function argument `node`

Check failure on line 191 in lewis/adapters/opcua.py

View workflow job for this annotation

GitHub Actions / call-linter-workflow / ruff

Ruff (ANN202)

lewis/adapters/opcua.py:191:25: ANN202 Missing return type annotation for private function `write_callback`
with self.device_lock:
setattr(self.interface, property_name, val)
return True
return write_callback

#set the callback
var.set_value_callback = make_callback(property)


def _add_methods(self, idx:int, device_node: Node) -> None:
"""
Add device methods as OPCUA methods.

:param idx: Namespace index
:param device_node: Device node to add the methods to
"""

for method_name in dir(self.interface):
#Skip properties and internal/special methods
if(not callable(getattr(self.interface, method_name)) or
method_name.startswith('_')):
continue

#Get the method
method = getattr(self.interface, method_name)

#Get info about the method's parameters
try:
signature = inspect.signature(method)

#Create input argument descriptions
inputs = []
for param_name, param in signature.parameters.items():
if param_name == 'self':
continue

#Add input argument
inputs.append(ua.Argument(
name=param_name,
data_type=ua.NodeId(ua.ObjectIds.Variant),
value_rank=-1,
array_dimensions=[],
description=""
))

outputs = [
ua.Argument(
name="Result",
data_type=ua.NodeId(ua.ObjectIds.Variant),
value_rank=-1,
array_dimensions=[],
description=""
)
]

#Create a wrapper to call the device method
def method_wrapper(parent, *args):
with self.device_lock:
result = getattr(self.interface, method_name)(*args)
return [result] if result is not None else []

device_node.add_method(
idx,
method_name,
method_wrapper,
inputs,
outputs
)
except Exception as e:
self.log.warning(f"Failed to add method {method_name}: {e}")

def _get_ua_data_type(self, value: Any) -> ua.VariantType:
"""
Determine the OPCUA data type for a given value.

:param value: The value to determine the data type for
:return: OPCUA Variant Type
"""

if isinstance(value, bool):
return ua.VariantType.Boolean
elif isinstance(value, int):
return ua.VariantType.Int64
elif isinstance(value, float):
return ua.VariantType.Double
elif isinstance(value, str):
return ua.VariantType.String
elif isinstance(value, list):
# For lists, use a more specific type if possible
if all(isinstance(x, bool) for x in value):
return ua.VariantType.Boolean
elif all(isinstance(x, int) for x in value):
return ua.VariantType.Int64
elif all(isinstance(x, float) for x in value or isinstance(x, int) for x in value):
return ua.VariantType.Double
else:
return ua.VariantType.Variant
else:
# Default to variant for complex types
return ua.VariantType.Variant


def stop_server(self):
"""
Stop the OPCUA server.

This method stops the update thread and shuts down the OPCUA server.
"""

if not self._running:
return

#Stop the update thread
self._stop_event.set()
if self._update_thread:
self._update_thread.join(timeout=2.0)
self._update_thread = None

#Stop the server
if self._server:
self._server.stop()
self._server = None

self._running = False
self._nodes = {}
self._property_values = {}

self.log.info("OPCUA server stopped")

@property
def is_running(self) -> bool:
"""
Check if the OPCUA server is running.

:return: True if server running, False otherwise
"""

return self._running

def handle(self, cycle_delay: float = 0.1) -> None:

Check notice

Code scanning / CodeQL

Unused local variable Note

Variable handle is not used.

Copilot Autofix

AI 6 months ago

To address the issue, we need to either:

  1. Remove the handle method if it is genuinely unused and serves no purpose in the broader application.
  2. Rename the method to indicate that it is intentionally unused (e.g., _unused_handle) if it is kept for documentation or future use.

Since the method includes logic and a docstring, it is likely intended for use. If it is not currently used but might be in the future, renaming it to _unused_handle is the best approach to clarify its status while preserving its implementation.


Suggested changeset 1
lewis/adapters/opcua.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lewis/adapters/opcua.py b/lewis/adapters/opcua.py
--- a/lewis/adapters/opcua.py
+++ b/lewis/adapters/opcua.py
@@ -330,3 +330,3 @@
         
-        def handle(self, cycle_delay: float = 0.1) -> None:
+        def _unused_handle(self, cycle_delay: float = 0.1) -> None:
             """
EOF
@@ -330,3 +330,3 @@

def handle(self, cycle_delay: float = 0.1) -> None:
def _unused_handle(self, cycle_delay: float = 0.1) -> None:
"""
Copilot is powered by AI and may make mistakes. Always verify output.
"""
Handle OPCUA operations.

This method is called periodically by Lewis. For OPCUA, most of the
handling is done by the server thread, so this method mainly waits.

:param cycle_delay: Approximate time to spend handling requests
"""

#Most handling is done by the OPCUA server itself
if self._running and self.interface:
time.sleep(min(cycle_delay, self._options.update_interval))

def _update_variables(self) -> None:

Check notice

Code scanning / CodeQL

Unused local variable Note

Variable _update_variables is not used.

Copilot Autofix

AI 6 months ago

To address the issue, we will rename the _update_variables method to _unused_update_variables to indicate that it is intentionally unused. This approach preserves the method for potential future use while making it clear to readers and static analysis tools that it is not currently invoked. No functionality will be altered, and the fix will only involve renaming the method.


Suggested changeset 1
lewis/adapters/opcua.py

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/lewis/adapters/opcua.py b/lewis/adapters/opcua.py
--- a/lewis/adapters/opcua.py
+++ b/lewis/adapters/opcua.py
@@ -344,3 +344,3 @@
 
-        def _update_variables(self) -> None:
+        def _unused_update_variables(self) -> None:
             """
EOF
@@ -344,3 +344,3 @@

def _update_variables(self) -> None:
def _unused_update_variables(self) -> None:
"""
Copilot is powered by AI and may make mistakes. Always verify output.
"""
Update OPCUA variables with current device values.

This method runs in a separate thread and periodically updates the OPCUA
variables with the current values from the device.
"""

while not self._stop_event.is_set() and self._running and self.interface:
#Update variables that have changed
with self.device_lock:
for property, node in self._nodes.items():
if hasattr(self.interface, property):
current_value = getattr(self.interface, property)

#check if the value has changed
if property not in self._property_values or self._property_values[property] != current_value:
try:
node.set_value(current_value)
self._property_values[property] = current_value
except Exception as e:
self.log.warning(f"Failed to update node {property}: {e}")

self._stop_event.wait(self._options.update_interval)

Loading
Loading