mirror of
https://github.com/dptech-corp/Uni-Lab-OS.git
synced 2025-12-17 13:01:12 +00:00
标准化opcua设备接入unilab (#78)
* 初始提交,只保留工作区当前状态 * remove redundant arm_slider meshes --------- Co-authored-by: Junhan Chang <changjh@dp.tech>
This commit is contained in:
19
unilabos/device_comms/opcua_client/README.md
Normal file
19
unilabos/device_comms/opcua_client/README.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# OPC UA 通用客户端
|
||||
|
||||
本模块提供了一个通用的 OPC UA 客户端实现,可以通过外部配置(CSV文件)来定义节点,并通过JSON配置来执行工作流。
|
||||
|
||||
## 特点
|
||||
|
||||
- 支持通过 CSV 文件配置 OPC UA 节点(只需提供名称、类型和数据类型,支持节点为中文名,需指定NodeLanguage)
|
||||
- 自动查找服务器中的节点,无需知道确切的节点ID
|
||||
- 提供工作流机制
|
||||
- 支持通过 JSON 配置创建工作流
|
||||
|
||||
## 使用方法
|
||||
|
||||
step1: 准备opcua_nodes.csv文件
|
||||
step2: 编写opcua_workflow_example.json,以定义工作流。指定opcua_nodes.csv
|
||||
step3: 编写工作流对应action
|
||||
step4: 编写opcua_example.yaml注册表
|
||||
step5: 编写opcua_example.json组态图。指定opcua_workflow_example.json定义工作流文件
|
||||
|
||||
12
unilabos/device_comms/opcua_client/__init__.py
Normal file
12
unilabos/device_comms/opcua_client/__init__.py
Normal file
@@ -0,0 +1,12 @@
|
||||
from unilabos.device_comms.opcua_client.client_o import OpcUaClient, BaseClient
|
||||
from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, Object, NodeType, DataType
|
||||
|
||||
__all__ = [
|
||||
'OpcUaClient',
|
||||
'BaseClient',
|
||||
'Variable',
|
||||
'Method',
|
||||
'Object',
|
||||
'NodeType',
|
||||
'DataType',
|
||||
]
|
||||
1380
unilabos/device_comms/opcua_client/client.py
Normal file
1380
unilabos/device_comms/opcua_client/client.py
Normal file
File diff suppressed because it is too large
Load Diff
10
unilabos/device_comms/opcua_client/node/__init__.py
Normal file
10
unilabos/device_comms/opcua_client/node/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from unilabos.device_comms.opcua_client.node.uniopcua import Variable, Method, Object, NodeType, DataType, Base
|
||||
|
||||
__all__ = [
|
||||
'Variable',
|
||||
'Method',
|
||||
'Object',
|
||||
'NodeType',
|
||||
'DataType',
|
||||
'Base',
|
||||
]
|
||||
180
unilabos/device_comms/opcua_client/node/uniopcua.py
Normal file
180
unilabos/device_comms/opcua_client/node/uniopcua.py
Normal file
@@ -0,0 +1,180 @@
|
||||
# coding=utf-8
|
||||
from enum import Enum
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Tuple, Union, Optional, Any, List
|
||||
|
||||
from opcua import Client, Node
|
||||
from opcua.ua import NodeId, NodeClass, VariantType
|
||||
|
||||
|
||||
class DataType(Enum):
|
||||
BOOLEAN = VariantType.Boolean
|
||||
SBYTE = VariantType.SByte
|
||||
BYTE = VariantType.Byte
|
||||
INT16 = VariantType.Int16
|
||||
UINT16 = VariantType.UInt16
|
||||
INT32 = VariantType.Int32
|
||||
UINT32 = VariantType.UInt32
|
||||
INT64 = VariantType.Int64
|
||||
UINT64 = VariantType.UInt64
|
||||
FLOAT = VariantType.Float
|
||||
DOUBLE = VariantType.Double
|
||||
STRING = VariantType.String
|
||||
DATETIME = VariantType.DateTime
|
||||
BYTESTRING = VariantType.ByteString
|
||||
|
||||
|
||||
class NodeType(Enum):
|
||||
VARIABLE = NodeClass.Variable
|
||||
OBJECT = NodeClass.Object
|
||||
METHOD = NodeClass.Method
|
||||
OBJECTTYPE = NodeClass.ObjectType
|
||||
VARIABLETYPE = NodeClass.VariableType
|
||||
REFERENCETYPE = NodeClass.ReferenceType
|
||||
DATATYPE = NodeClass.DataType
|
||||
VIEW = NodeClass.View
|
||||
|
||||
|
||||
class Base(ABC):
|
||||
def __init__(self, client: Client, name: str, node_id: str, typ: NodeType, data_type: DataType):
|
||||
self._node_id: str = node_id
|
||||
self._client = client
|
||||
self._name = name
|
||||
self._type = typ
|
||||
self._data_type = data_type
|
||||
self._node: Optional[Node] = None
|
||||
|
||||
def _get_node(self) -> Node:
|
||||
if self._node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._node = self._client.get_node(node_id)
|
||||
else:
|
||||
raise ValueError(f"无法解析节点ID: {self._node_id}")
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
self._node = self._client.get_node(self._node_id)
|
||||
except Exception as e:
|
||||
print(f"获取节点失败: {self._node_id}, 错误: {e}")
|
||||
raise
|
||||
return self._node
|
||||
|
||||
@abstractmethod
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""读取节点值,返回(值, 是否出错)"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def write(self, value: Any) -> bool:
|
||||
"""写入节点值,返回是否出错"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def type(self) -> NodeType:
|
||||
return self._type
|
||||
|
||||
@property
|
||||
def node_id(self) -> str:
|
||||
return self._node_id
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self._name
|
||||
|
||||
|
||||
class Variable(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str, data_type: DataType):
|
||||
super().__init__(client, name, node_id, NodeType.VARIABLE, data_type)
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
try:
|
||||
value = self._get_node().get_value()
|
||||
return value, False
|
||||
except Exception as e:
|
||||
print(f"读取变量 {self._name} 失败: {e}")
|
||||
return None, True
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
try:
|
||||
self._get_node().set_value(value)
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"写入变量 {self._name} 失败: {e}")
|
||||
return True
|
||||
|
||||
|
||||
class Method(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str, parent_node_id: str, data_type: DataType):
|
||||
super().__init__(client, name, node_id, NodeType.METHOD, data_type)
|
||||
self._parent_node_id = parent_node_id
|
||||
self._parent_node = None
|
||||
|
||||
def _get_parent_node(self) -> Node:
|
||||
if self._parent_node is None:
|
||||
try:
|
||||
# 检查是否是NumericNodeId(ns=X;i=Y)格式
|
||||
if "NumericNodeId" in self._parent_node_id:
|
||||
# 从字符串中提取命名空间和标识符
|
||||
import re
|
||||
match = re.search(r'ns=(\d+);i=(\d+)', self._parent_node_id)
|
||||
if match:
|
||||
ns = int(match.group(1))
|
||||
identifier = int(match.group(2))
|
||||
node_id = NodeId(identifier, ns)
|
||||
self._parent_node = self._client.get_node(node_id)
|
||||
else:
|
||||
raise ValueError(f"无法解析父节点ID: {self._parent_node_id}")
|
||||
else:
|
||||
# 直接使用节点ID字符串
|
||||
self._parent_node = self._client.get_node(self._parent_node_id)
|
||||
except Exception as e:
|
||||
print(f"获取父节点失败: {self._parent_node_id}, 错误: {e}")
|
||||
raise
|
||||
return self._parent_node
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""方法节点不支持读取操作"""
|
||||
return None, True
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
"""方法节点不支持写入操作"""
|
||||
return True
|
||||
|
||||
def call(self, *args) -> Tuple[Any, bool]:
|
||||
"""调用方法,返回(返回值, 是否出错)"""
|
||||
try:
|
||||
result = self._get_parent_node().call_method(self._get_node(), *args)
|
||||
return result, False
|
||||
except Exception as e:
|
||||
print(f"调用方法 {self._name} 失败: {e}")
|
||||
return None, True
|
||||
|
||||
|
||||
class Object(Base):
|
||||
def __init__(self, client: Client, name: str, node_id: str):
|
||||
super().__init__(client, name, node_id, NodeType.OBJECT, None)
|
||||
|
||||
def read(self) -> Tuple[Any, bool]:
|
||||
"""对象节点不支持直接读取操作"""
|
||||
return None, True
|
||||
|
||||
def write(self, value: Any) -> bool:
|
||||
"""对象节点不支持直接写入操作"""
|
||||
return True
|
||||
|
||||
def get_children(self) -> Tuple[List[Node], bool]:
|
||||
"""获取子节点列表,返回(子节点列表, 是否出错)"""
|
||||
try:
|
||||
children = self._get_node().get_children()
|
||||
return children, False
|
||||
except Exception as e:
|
||||
print(f"获取对象 {self._name} 的子节点失败: {e}")
|
||||
return [], True
|
||||
@@ -0,0 +1,2 @@
|
||||
Name,EnglishName,NodeType,DataType,NodeLanguage
|
||||
中文名,EnglishName,VARIABLE,INT32,Chinese
|
||||
|
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"register_node_list_from_csv_path": {
|
||||
"path": "opcua_nodes_example.csv"
|
||||
},
|
||||
"create_flow": [
|
||||
{
|
||||
"name": "name",
|
||||
"description": "description",
|
||||
"parameters": ["parameter1", "parameter2"],
|
||||
"action": [
|
||||
{
|
||||
"init_function": {
|
||||
"func_name": "init_grab_params",
|
||||
"write_nodes": ["parameter1", "parameter2"]
|
||||
},
|
||||
"start_function": {
|
||||
"func_name": "start_grab",
|
||||
"write_nodes": {"parameter_start": true},
|
||||
"condition_nodes": ["parameter_condition"],
|
||||
"stop_condition_expression": "parameter_condition == True"
|
||||
},
|
||||
"stop_function": {
|
||||
"func_name": "stop_grab",
|
||||
"write_nodes": {"parameter_start": false}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
311
unilabos/device_comms/opcua_client/server.py
Normal file
311
unilabos/device_comms/opcua_client/server.py
Normal file
@@ -0,0 +1,311 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
OPC UA测试服务器
|
||||
用于测试OPC UA客户端功能,特别是temperature_control和valve_control工作流
|
||||
"""
|
||||
|
||||
import sys
|
||||
import time
|
||||
import logging
|
||||
from opcua import Server, ua
|
||||
import threading
|
||||
|
||||
# 设置日志
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class OpcUaTestServer:
|
||||
"""OPC UA测试服务器类"""
|
||||
|
||||
def __init__(self, endpoint="opc.tcp://localhost:4840/freeopcua/server/"):
|
||||
"""
|
||||
初始化OPC UA服务器
|
||||
|
||||
Args:
|
||||
endpoint: 服务器端点URL
|
||||
"""
|
||||
self.server = Server()
|
||||
self.server.set_endpoint(endpoint)
|
||||
|
||||
# 设置服务器名称
|
||||
self.server.set_server_name("UniLabOS OPC UA Test Server")
|
||||
|
||||
# 设置服务器命名空间
|
||||
self.idx = self.server.register_namespace("http://unilabos.com/opcua/test")
|
||||
|
||||
# 获取Objects节点
|
||||
self.objects = self.server.get_objects_node()
|
||||
|
||||
# 创建设备对象
|
||||
self.device = self.objects.add_object(self.idx, "TestDevice")
|
||||
|
||||
# 存储所有节点的字典
|
||||
self.nodes = {}
|
||||
|
||||
# 初始化标志
|
||||
self.running = False
|
||||
|
||||
# 控制标志
|
||||
self.simulation_active = True
|
||||
|
||||
def add_variable(self, name, value, data_type=None):
|
||||
"""
|
||||
添加变量节点
|
||||
|
||||
Args:
|
||||
name: 变量名称
|
||||
value: 初始值
|
||||
data_type: 数据类型 (可选)
|
||||
"""
|
||||
if data_type is None:
|
||||
var = self.device.add_variable(self.idx, name, value)
|
||||
else:
|
||||
var = self.device.add_variable(self.idx, name, value, data_type)
|
||||
|
||||
# 设置变量可写
|
||||
var.set_writable()
|
||||
|
||||
# 存储节点
|
||||
self.nodes[name] = var
|
||||
logger.info(f"添加变量节点: {name}, 初始值: {value}")
|
||||
return var
|
||||
|
||||
def add_method(self, name, callback, inputs=None, outputs=None):
|
||||
"""
|
||||
添加方法节点
|
||||
|
||||
Args:
|
||||
name: 方法名称
|
||||
callback: 回调函数
|
||||
inputs: 输入参数列表 [(name, type), ...]
|
||||
outputs: 输出参数列表 [(name, type), ...]
|
||||
"""
|
||||
if inputs is None:
|
||||
inputs = []
|
||||
if outputs is None:
|
||||
outputs = []
|
||||
|
||||
# 创建输入参数
|
||||
input_args = []
|
||||
for arg_name, arg_type in inputs:
|
||||
input_args.append(ua.Argument())
|
||||
input_args[-1].Name = arg_name
|
||||
input_args[-1].DataType = arg_type
|
||||
input_args[-1].ValueRank = -1
|
||||
|
||||
# 创建输出参数
|
||||
output_args = []
|
||||
for arg_name, arg_type in outputs:
|
||||
output_args.append(ua.Argument())
|
||||
output_args[-1].Name = arg_name
|
||||
output_args[-1].DataType = arg_type
|
||||
output_args[-1].ValueRank = -1
|
||||
|
||||
# 添加方法
|
||||
method = self.device.add_method(
|
||||
self.idx,
|
||||
name,
|
||||
callback,
|
||||
input_args,
|
||||
output_args
|
||||
)
|
||||
|
||||
# 存储节点
|
||||
self.nodes[name] = method
|
||||
logger.info(f"添加方法节点: {name}")
|
||||
return method
|
||||
|
||||
def start(self):
|
||||
"""启动服务器"""
|
||||
if not self.running:
|
||||
self.server.start()
|
||||
self.running = True
|
||||
logger.info("OPC UA服务器已启动")
|
||||
|
||||
# 启动模拟线程
|
||||
self.simulation_thread = threading.Thread(target=self.run_simulation)
|
||||
self.simulation_thread.daemon = True
|
||||
self.simulation_thread.start()
|
||||
|
||||
def stop(self):
|
||||
"""停止服务器"""
|
||||
if self.running:
|
||||
self.simulation_active = False
|
||||
if hasattr(self, 'simulation_thread'):
|
||||
self.simulation_thread.join(timeout=2)
|
||||
self.server.stop()
|
||||
self.running = False
|
||||
logger.info("OPC UA服务器已停止")
|
||||
|
||||
def get_node(self, name):
|
||||
"""获取节点"""
|
||||
if name in self.nodes:
|
||||
return self.nodes[name]
|
||||
return None
|
||||
|
||||
def update_variable(self, name, value):
|
||||
"""更新变量值"""
|
||||
if name in self.nodes:
|
||||
self.nodes[name].set_value(value)
|
||||
logger.debug(f"更新变量 {name} = {value}")
|
||||
return True
|
||||
logger.warning(f"变量 {name} 不存在")
|
||||
return False
|
||||
|
||||
def run_simulation(self):
|
||||
"""运行模拟线程"""
|
||||
logger.info("启动模拟线程")
|
||||
|
||||
temp = 20.0
|
||||
valve_position = 0.0
|
||||
flow_rate = 0.0
|
||||
|
||||
while self.simulation_active and self.running:
|
||||
try:
|
||||
# 温度控制模拟
|
||||
heating_enabled = self.get_node("HeatingEnabled").get_value()
|
||||
setpoint = self.get_node("Setpoint").get_value()
|
||||
|
||||
if heating_enabled:
|
||||
self.update_variable("HeatingStatus", True)
|
||||
if temp < setpoint:
|
||||
temp += 0.5 # 加快温度上升速度
|
||||
else:
|
||||
temp -= 0.1
|
||||
else:
|
||||
self.update_variable("HeatingStatus", False)
|
||||
if temp > 20.0:
|
||||
temp -= 0.2
|
||||
|
||||
# 更新温度
|
||||
self.update_variable("Temperature", round(temp, 2))
|
||||
|
||||
# 阀门控制模拟
|
||||
valve_control = self.get_node("ValveControl").get_value()
|
||||
valve_setpoint = self.get_node("ValveSetpoint").get_value()
|
||||
|
||||
if valve_control:
|
||||
if valve_position < valve_setpoint:
|
||||
valve_position += 5.0 # 加快阀门开启速度
|
||||
if valve_position > valve_setpoint:
|
||||
valve_position = valve_setpoint
|
||||
else:
|
||||
valve_position -= 1.0
|
||||
if valve_position < 0:
|
||||
valve_position = 0
|
||||
else:
|
||||
if valve_position > 0:
|
||||
valve_position -= 5.0
|
||||
if valve_position < 0:
|
||||
valve_position = 0
|
||||
|
||||
# 更新阀门位置
|
||||
self.update_variable("ValvePosition", round(valve_position, 2))
|
||||
|
||||
# 流量模拟 - 与阀门位置成正比
|
||||
flow_rate = valve_position * 0.2 # 简单线性关系
|
||||
self.update_variable("FlowRate", round(flow_rate, 2))
|
||||
|
||||
# 更新系统状态
|
||||
status = []
|
||||
if heating_enabled:
|
||||
status.append("Heating")
|
||||
if valve_control:
|
||||
status.append("Valve_Open")
|
||||
|
||||
if status:
|
||||
self.update_variable("SystemStatus", "_".join(status))
|
||||
else:
|
||||
self.update_variable("SystemStatus", "Idle")
|
||||
|
||||
# 每200毫秒更新一次
|
||||
time.sleep(0.2)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"模拟线程错误: {e}")
|
||||
time.sleep(1) # 出错时稍等一会再继续
|
||||
|
||||
logger.info("模拟线程已停止")
|
||||
|
||||
def reset_alarm_callback(parent, *args):
|
||||
"""重置报警的回调函数"""
|
||||
logger.info("调用了重置报警方法")
|
||||
return True
|
||||
|
||||
def start_process_callback(parent, *args):
|
||||
"""启动流程的回调函数"""
|
||||
process_id = args[0] if args else 0
|
||||
logger.info(f"启动流程 ID: {process_id}")
|
||||
return process_id
|
||||
|
||||
def stop_process_callback(parent, *args):
|
||||
"""停止流程的回调函数"""
|
||||
process_id = args[0] if args else 0
|
||||
logger.info(f"停止流程 ID: {process_id}")
|
||||
return True
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
try:
|
||||
# 创建服务器
|
||||
server = OpcUaTestServer()
|
||||
|
||||
# 添加变量节点 - 温度控制相关
|
||||
server.add_variable("Temperature", 20.0, ua.VariantType.Float)
|
||||
server.add_variable("Setpoint", 22.0, ua.VariantType.Float)
|
||||
server.add_variable("HeatingEnabled", False, ua.VariantType.Boolean)
|
||||
server.add_variable("HeatingStatus", False, ua.VariantType.Boolean)
|
||||
|
||||
# 添加变量节点 - 阀门控制相关
|
||||
server.add_variable("ValvePosition", 0.0, ua.VariantType.Float)
|
||||
server.add_variable("ValveSetpoint", 0.0, ua.VariantType.Float)
|
||||
server.add_variable("ValveControl", False, ua.VariantType.Boolean)
|
||||
server.add_variable("FlowRate", 0.0, ua.VariantType.Float)
|
||||
|
||||
# 其他状态变量
|
||||
server.add_variable("SystemStatus", "Idle", ua.VariantType.String)
|
||||
|
||||
# 添加方法节点
|
||||
server.add_method(
|
||||
"ResetAlarm",
|
||||
reset_alarm_callback,
|
||||
[],
|
||||
[("Result", ua.VariantType.Boolean)]
|
||||
)
|
||||
|
||||
server.add_method(
|
||||
"StartProcess",
|
||||
start_process_callback,
|
||||
[("ProcessId", ua.VariantType.Int32)],
|
||||
[("Result", ua.VariantType.Int32)]
|
||||
)
|
||||
|
||||
server.add_method(
|
||||
"StopProcess",
|
||||
stop_process_callback,
|
||||
[("ProcessId", ua.VariantType.Int32)],
|
||||
[("Result", ua.VariantType.Boolean)]
|
||||
)
|
||||
|
||||
# 启动服务器
|
||||
server.start()
|
||||
logger.info("服务器已启动,按Ctrl+C停止")
|
||||
|
||||
# 保持服务器运行
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
except KeyboardInterrupt:
|
||||
logger.info("收到键盘中断,正在停止服务器...")
|
||||
|
||||
# 停止服务器
|
||||
server.stop()
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"服务器错误: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
12
unilabos/registry/devices/opcua_example.yaml
Normal file
12
unilabos/registry/devices/opcua_example.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
opcua_example:
|
||||
class:
|
||||
action_value_mappings:
|
||||
|
||||
module: unilabos.device_comms.opcua_client.client:OpcUaClient
|
||||
status_types:
|
||||
|
||||
type: python
|
||||
description:
|
||||
handles: []
|
||||
icon: ''
|
||||
init_param_schema: {}
|
||||
Reference in New Issue
Block a user